/*
|
* The contents of this file are subject to the terms of the Common Development and
|
* Distribution License (the License). You may not use this file except in compliance with the
|
* License.
|
*
|
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
|
* specific language governing permission and limitations under the License.
|
*
|
* When distributing Covered Software, include this CDDL Header Notice in each file and include
|
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
|
* Header, with the fields enclosed by brackets [] replaced by your own identifying
|
* information: "Portions copyright [year] [name of copyright owner]".
|
*
|
* Copyright 2026 3A Systems, LLC.
|
*/
|
package org.opends.quicksetup.util;
|
|
import static com.forgerock.opendj.util.OperatingSystem.*;
|
import static org.opends.messages.QuickSetupMessages.INFO_ERROR_STARTING_SERVER_CODE;
|
import static org.testng.Assert.*;
|
|
import java.io.File;
|
import java.io.IOException;
|
import java.nio.charset.StandardCharsets;
|
import java.nio.file.Files;
|
|
import org.forgerock.i18n.LocalizableMessage;
|
import org.opends.quicksetup.Application;
|
import org.opends.quicksetup.ApplicationException;
|
import org.opends.quicksetup.Installation;
|
import org.opends.quicksetup.ProgressStep;
|
import org.opends.quicksetup.ReturnCode;
|
import org.opends.server.DirectoryServerTestCase;
|
import org.testng.SkipException;
|
import org.testng.annotations.AfterMethod;
|
import org.testng.annotations.BeforeMethod;
|
import org.testng.annotations.Test;
|
|
/**
|
* A failed start reports what the start command printed (issue #1160), unless the listeners of
|
* the application have already shown it. The installation is a bare directory whose start command
|
* is a stand-in script that prints and exits with 1.
|
*/
|
@SuppressWarnings("javadoc")
|
@Test(sequential = true)
|
public class ServerControllerStartFailureTest extends DirectoryServerTestCase
|
{
|
private File root;
|
|
@BeforeMethod
|
public void createInstallation() throws IOException
|
{
|
root = Files.createTempDirectory("server-controller-start-failure").toFile();
|
}
|
|
@AfterMethod(alwaysRun = true)
|
public void deleteInstallation() throws ApplicationException
|
{
|
new FileManager().deleteRecursively(root);
|
}
|
|
@Test
|
public void failedStartReportsBothOutputStreams() throws Exception
|
{
|
writeStartCommandPrintingToBothStreams();
|
|
String message = startAndExpectFailure(null, false);
|
|
assertTrue(message.startsWith(exitCodeMessage()), message);
|
assertTrue(message.contains("cause on stdout"), message);
|
assertTrue(message.contains("cause on stderr"), message);
|
}
|
|
@Test
|
public void failedStartReportsOnlyTheLastLines() throws Exception
|
{
|
if (isWindows())
|
{
|
writeStartCommand("@for /L %%i in (1,1,150) do @echo L%%iL", "@exit /b 1");
|
}
|
else
|
{
|
writeStartCommand("#!/bin/sh", "i=1", "while [ $i -le 150 ]; do echo \"L${i}L\"; i=$((i+1)); done", "exit 1");
|
}
|
|
String message = startAndExpectFailure(null, false);
|
|
assertFalse(message.contains("L50L"), message);
|
assertTrue(message.contains("L51L"), message);
|
assertTrue(message.contains("L150L"), message);
|
}
|
|
@Test
|
public void failedStartReportsWhatIsPrintedAfterTheExit() throws Exception
|
{
|
if (isWindows())
|
{
|
throw new SkipException("needs a child process that outlives the start command");
|
}
|
// The child holds the inherited pipes after sh exits, so its line arrives after waitFor().
|
// When the process exits, the JDK drains what is already in a pipe and closes it, unless a
|
// reader is blocked in read() on that stream, which the drain then waits for. The first
|
// line and the pause before the exit leave the stdout reader blocked there, so that the
|
// line of the child is read rather than cut off by the drain.
|
writeStartCommand("#!/bin/sh", "echo 'printed before the exit'", "sleep 1",
|
"(sleep 1; echo 'printed after the exit') &", "exit 1");
|
|
String message = startAndExpectFailure(null, false);
|
|
assertTrue(message.contains("printed before the exit"), message);
|
assertTrue(message.contains("printed after the exit"), message);
|
}
|
|
@Test
|
public void silentFailedStartKeepsTheExitCodeMessage() throws Exception
|
{
|
if (isWindows())
|
{
|
writeStartCommand("@exit /b 1");
|
}
|
else
|
{
|
writeStartCommand("#!/bin/sh", "exit 1");
|
}
|
|
assertEquals(startAndExpectFailure(null, false), exitCodeMessage());
|
}
|
|
@Test
|
public void failedStartShownToTheListenersKeepsTheExitCodeMessage() throws Exception
|
{
|
writeStartCommandPrintingToBothStreams();
|
ListeningApplication application = new ListeningApplication();
|
|
assertEquals(startAndExpectFailure(application, false), exitCodeMessage());
|
assertTrue(application.getShown().contains("cause on stdout"), application.getShown());
|
assertTrue(application.getShown().contains("cause on stderr"), application.getShown());
|
}
|
|
@Test
|
public void failedStartHiddenFromTheListenersReportsBothOutputStreams() throws Exception
|
{
|
writeStartCommandPrintingToBothStreams();
|
ListeningApplication application = new ListeningApplication();
|
|
String message = startAndExpectFailure(application, true);
|
|
assertTrue(message.startsWith(exitCodeMessage()), message);
|
assertTrue(message.contains("cause on stdout"), message);
|
assertTrue(message.contains("cause on stderr"), message);
|
assertEquals(application.getShown(), "");
|
}
|
|
private static String exitCodeMessage()
|
{
|
return INFO_ERROR_STARTING_SERVER_CODE.get(1).toString();
|
}
|
|
private void writeStartCommandPrintingToBothStreams() throws IOException
|
{
|
if (isWindows())
|
{
|
writeStartCommand("@echo cause on stdout", "@echo cause on stderr 1>&2", "@exit /b 1");
|
}
|
else
|
{
|
writeStartCommand("#!/bin/sh", "echo 'cause on stdout'", "echo 'cause on stderr' >&2", "exit 1");
|
}
|
}
|
|
private void writeStartCommand(String... lines) throws IOException
|
{
|
Installation installation = new Installation(root, root);
|
File command = installation.getServerStartCommandFile();
|
assertTrue(command.getParentFile().mkdirs());
|
Files.write(command.toPath(), String.join(isWindows() ? "\r\n" : "\n", lines).concat("\n")
|
.getBytes(StandardCharsets.US_ASCII));
|
assertTrue(command.setExecutable(true));
|
}
|
|
private String startAndExpectFailure(Application application, boolean suppressOutput)
|
{
|
try
|
{
|
new ServerController(application, new Installation(root, root)).startServer(suppressOutput);
|
fail("The start command exited with 1, but the start did not fail");
|
return null;
|
}
|
catch (ApplicationException e)
|
{
|
assertEquals(e.getType(), ReturnCode.START_ERROR);
|
return e.getMessage();
|
}
|
}
|
|
/** An application whose listener keeps the log details it is shown. */
|
private static final class ListeningApplication extends Application
|
{
|
private final StringBuilder shown = new StringBuilder();
|
|
ListeningApplication()
|
{
|
setProgressMessageFormatter(new PlainTextProgressMessageFormatter());
|
addProgressUpdateListener(ev ->
|
{
|
synchronized (shown)
|
{
|
shown.append(ev.getNewLogs());
|
}
|
});
|
}
|
|
String getShown()
|
{
|
synchronized (shown)
|
{
|
return shown.toString();
|
}
|
}
|
|
@Override
|
public String getInstallationPath()
|
{
|
return null;
|
}
|
|
@Override
|
public String getInstancePath()
|
{
|
return null;
|
}
|
|
@Override
|
public ProgressStep getCurrentProgressStep()
|
{
|
return null;
|
}
|
|
@Override
|
public Integer getRatio(ProgressStep step)
|
{
|
return null;
|
}
|
|
@Override
|
public LocalizableMessage getSummary(ProgressStep step)
|
{
|
return null;
|
}
|
|
@Override
|
public boolean isFinished()
|
{
|
return false;
|
}
|
|
@Override
|
public boolean isCancellable()
|
{
|
return false;
|
}
|
|
@Override
|
public void cancel()
|
{
|
// Nothing to cancel.
|
}
|
|
@Override
|
public void run()
|
{
|
// Never run: the test drives the ServerController directly.
|
}
|
}
|
}
|