-
Notifications
You must be signed in to change notification settings - Fork 96
[JEP-206] Define API for gathering command output in a local encoding #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
e703cc8
cc53b18
6df38ba
d04d08c
761202e
6e94552
6da5620
7208013
d096a91
7b01461
b3b0e4e
32de5bf
f8c292c
1aa8974
9dd828f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,23 +37,26 @@ | |
| import hudson.util.StreamTaskListener; | ||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.io.OutputStream; | ||
| import java.io.RandomAccessFile; | ||
| import java.io.StringWriter; | ||
| import java.nio.ByteBuffer; | ||
| import java.nio.channels.Channels; | ||
| import java.nio.charset.Charset; | ||
| import java.nio.charset.CodingErrorAction; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.Collections; | ||
| import java.util.Map; | ||
| import java.util.TreeMap; | ||
| import java.util.UUID; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import javax.annotation.CheckForNull; | ||
| import javax.annotation.Nonnull; | ||
| import jenkins.MasterToSlaveFileCallable; | ||
| import org.apache.commons.io.IOUtils; | ||
| import org.apache.commons.io.FileUtils; | ||
| import org.apache.commons.io.output.CountingOutputStream; | ||
|
|
||
| import javax.annotation.CheckForNull; | ||
|
|
||
| /** | ||
| * A task which forks some external command and then waits for log and status files to be updated/created. | ||
| */ | ||
|
|
@@ -63,19 +66,37 @@ public abstract class FileMonitoringTask extends DurableTask { | |
|
|
||
| private static final String COOKIE = "JENKINS_SERVER_COOKIE"; | ||
|
|
||
| /** Value of {@link #charset} used to mean the node’s system default. */ | ||
| private static final String SYSTEM_DEFAULT_CHARSET = "SYSTEM_DEFAULT"; | ||
|
|
||
| /** | ||
| * Charset name to use for transcoding, or {@link #SYSTEM_DEFAULT_CHARSET}, or null for no transcoding. | ||
| */ | ||
| private @CheckForNull String charset; | ||
|
|
||
| private static String cookieFor(FilePath workspace) { | ||
| return "durable-" + Util.getDigestOf(workspace.getRemote()); | ||
| } | ||
|
|
||
| @Override public final Controller launch(EnvVars env, FilePath workspace, Launcher launcher, TaskListener listener) throws IOException, InterruptedException { | ||
| return launchWithCookie(workspace, launcher, listener, env, COOKIE, cookieFor(workspace)); | ||
| FileMonitoringController controller = launchWithCookie(workspace, launcher, listener, env, COOKIE, cookieFor(workspace)); | ||
| controller.charset = charset; | ||
| return controller; | ||
| } | ||
|
|
||
| protected FileMonitoringController launchWithCookie(FilePath workspace, Launcher launcher, TaskListener listener, EnvVars envVars, String cookieVariable, String cookieValue) throws IOException, InterruptedException { | ||
| envVars.put(cookieVariable, cookieValue); // ensure getCharacteristicEnvVars does not match, so Launcher.killAll will leave it alone | ||
| return doLaunch(workspace, launcher, listener, envVars); | ||
| } | ||
|
|
||
| @Override public final void charset(Charset cs) { | ||
| charset = cs.name(); | ||
| } | ||
|
|
||
| @Override public final void defaultCharset() { | ||
| charset = SYSTEM_DEFAULT_CHARSET; | ||
| } | ||
|
|
||
| /** | ||
| * Should start a process which sends output to {@linkplain FileMonitoringController#getLogFile(FilePath) log file} | ||
| * in the workspace and finally writes its exit code to {@linkplain FileMonitoringController#getResultFile(FilePath) result file}. | ||
|
|
@@ -115,6 +136,9 @@ protected static class FileMonitoringController extends Controller { | |
| */ | ||
| private long lastLocation; | ||
|
|
||
| /** @see FileMonitoringTask#charset */ | ||
| private @CheckForNull String charset; | ||
|
|
||
| protected FileMonitoringController(FilePath ws) throws IOException, InterruptedException { | ||
| // can't keep ws reference because Controller is expected to be serializable | ||
| ws.mkdirs(); | ||
|
|
@@ -127,7 +151,7 @@ protected FileMonitoringController(FilePath ws) throws IOException, InterruptedE | |
| FilePath log = getLogFile(workspace); | ||
| CountingOutputStream cos = new CountingOutputStream(sink); | ||
| try { | ||
| log.act(new WriteLog(lastLocation, new RemoteOutputStream(cos))); | ||
| log.act(new WriteLog(lastLocation, new RemoteOutputStream(cos), charset)); | ||
| return cos.getByteCount() > 0; | ||
| } finally { // even if RemoteOutputStream write was interrupted, record what we actually received | ||
| long written = cos.getByteCount(); | ||
|
|
@@ -140,9 +164,11 @@ protected FileMonitoringController(FilePath ws) throws IOException, InterruptedE | |
| private static class WriteLog extends MasterToSlaveFileCallable<Void> { | ||
| private final long lastLocation; | ||
| private final OutputStream sink; | ||
| WriteLog(long lastLocation, OutputStream sink) { | ||
| private final @CheckForNull String charset; | ||
| WriteLog(long lastLocation, OutputStream sink, String charset) { | ||
| this.lastLocation = lastLocation; | ||
| this.sink = sink; | ||
| this.charset = charset; | ||
| } | ||
| @Override public Void invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { | ||
| long len = f.length(); | ||
|
|
@@ -155,7 +181,12 @@ private static class WriteLog extends MasterToSlaveFileCallable<Void> { | |
| } | ||
| byte[] buf = new byte[(int) toRead]; | ||
| raf.readFully(buf); | ||
| sink.write(buf); | ||
| ByteBuffer transcoded = maybeTranscode(buf, charset); | ||
| if (transcoded == null) { | ||
| sink.write(buf); | ||
| } else { | ||
| Channels.newChannel(sink).write(transcoded); | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
|
|
@@ -195,13 +226,42 @@ public Integer invoke(File f, VirtualChannel channel) throws IOException, Interr | |
| return status.act(STATUS_CHECK_INSTANCE); | ||
| } | ||
|
|
||
| @Override public byte[] getOutput(FilePath workspace, Launcher launcher) throws IOException, InterruptedException { | ||
| // TODO could perhaps be more efficient for large files to send a MasterToSlaveFileCallable<byte[]> | ||
| try (InputStream is = getOutputFile(workspace).read()) { | ||
| return IOUtils.toByteArray(is); | ||
| /** | ||
| * Transcode process output to UTF-8 if necessary. | ||
| * @param data output presumed to be in local encoding | ||
| * @param charset a particular encoding name, or the empty string for the system default encoding, or null to skip transcoding | ||
| * @return a buffer of UTF-8 encoded data ({@link CodingErrorAction#REPLACE} is used), | ||
| * or null if not performing transcoding because it was not requested or the data was already thought to be in UTF-8 | ||
| */ | ||
| private static @CheckForNull ByteBuffer maybeTranscode(@Nonnull byte[] data, @CheckForNull String charset) { | ||
| if (charset == null) { // no transcoding requested, do raw copy and YMMV | ||
| return null; | ||
| } else { | ||
| Charset cs = charset.equals(SYSTEM_DEFAULT_CHARSET) ? Charset.defaultCharset() : Charset.forName(charset); | ||
| if (cs.equals(StandardCharsets.UTF_8)) { // transcoding unnecessary as output was already UTF-8 | ||
| return null; | ||
| } else { // decode output in specified charset and reëncode in UTF-8 | ||
| return StandardCharsets.UTF_8.encode(cs.decode(ByteBuffer.wrap(data))); | ||
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| @Override public byte[] getOutput(FilePath workspace, Launcher launcher) throws IOException, InterruptedException { | ||
| return getOutputFile(workspace).act(new MasterToSlaveFileCallable<byte[]>() { | ||
| @Override public byte[] invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { | ||
| byte[] buf = FileUtils.readFileToByteArray(f); | ||
|
||
| ByteBuffer transcoded = maybeTranscode(buf, charset); | ||
| if (transcoded == null) { | ||
| return buf; | ||
| } else { | ||
| byte[] buf2 = new byte[transcoded.remaining()]; | ||
| transcoded.get(buf2); | ||
| return buf2; | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| @Override public final void stop(FilePath workspace, Launcher launcher) throws IOException, InterruptedException { | ||
| launcher.kill(Collections.singletonMap(COOKIE, cookieFor(workspace))); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,8 +34,10 @@ | |
| import hudson.util.VersionNumber; | ||
| import java.io.ByteArrayOutputStream; | ||
| import java.io.File; | ||
| import java.nio.charset.Charset; | ||
| import java.util.Collections; | ||
| import java.util.logging.Level; | ||
| import jenkins.security.MasterToSlaveCallable; | ||
| import org.apache.commons.io.output.TeeOutputStream; | ||
| import static org.hamcrest.Matchers.*; | ||
| import org.jenkinsci.test.acceptance.docker.Docker; | ||
|
|
@@ -236,4 +238,61 @@ private void runOnDocker(DumbSlave s) throws Exception { | |
| runOnDocker(new DumbSlave("docker", "/home/jenkins/agent", new SimpleCommandLauncher("docker run -i --rm --name agent --init jenkinsci/slave:3.7-1 java -jar /usr/share/jenkins/slave.jar"))); | ||
| } | ||
|
|
||
| @Issue("JENKINS-31096") | ||
| @Test public void encoding() throws Exception { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is unreadable IMHO. Would it be possible to split it to several tests or at least explicitly indicate test stages? Assert logic could be also refactored to a separate method
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it could be refactored to use a helper assertion method with parameters.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (resolved) |
||
| JavaContainer container = dockerUbuntu.get(); | ||
| DumbSlave s = new DumbSlave("docker", "/home/test", new SSHLauncher(container.ipBound(22), container.port(22), "test", "test", "", "-Dfile.encoding=ISO-8859-1")); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that this test can run without jenkinsci/docker-fixtures#19 since it is only concerned with file contents, not name. (Passing |
||
| j.jenkins.addNode(s); | ||
| j.waitOnline(s); | ||
| assertEquals("ISO-8859-1", s.getChannel().call(new DetectCharset())); | ||
| FilePath dockerWS = s.getWorkspaceRoot(); | ||
| dockerWS.child("latin").write("¡Ole!", "ISO-8859-1"); | ||
| dockerWS.child("eastern").write("Čau!", "ISO-8859-2"); | ||
| dockerWS.child("mixed").write("¡Čau → there!", "UTF-8"); | ||
| Launcher dockerLauncher = s.createLauncher(listener); | ||
| assertEncoding("control: no transcoding", "latin", null, "¡Ole!", "ISO-8859-1", dockerWS, dockerLauncher); | ||
| assertEncoding("test: specify particular charset (UTF-8)", "mixed", "UTF-8", "¡Čau → there!", "UTF-8", dockerWS, dockerLauncher); | ||
| assertEncoding("test: specify particular charset (unrelated)", "eastern", "ISO-8859-2", "Čau!", "UTF-8", dockerWS, dockerLauncher); | ||
| assertEncoding("test: specify agent default charset", "latin", "", "¡Ole!", "UTF-8", dockerWS, dockerLauncher); | ||
| assertEncoding("test: inappropriate charset, some replacement characters", "mixed", "US-ASCII", "����au ��� there!", "UTF-8", dockerWS, dockerLauncher); | ||
| s.toComputer().disconnect(new OfflineCause.UserCause(null, null)); | ||
| } | ||
| private static class DetectCharset extends MasterToSlaveCallable<String, RuntimeException> { | ||
| @Override public String call() throws RuntimeException { | ||
| return Charset.defaultCharset().name(); | ||
| } | ||
| } | ||
| private void assertEncoding(String description, String file, String charset, String expected, String expectedEncoding, FilePath dockerWS, Launcher dockerLauncher) throws Exception { | ||
| assertEncoding(description, file, charset, expected, expectedEncoding, false, dockerWS, dockerLauncher); | ||
| assertEncoding(description, file, charset, expected, expectedEncoding, true, dockerWS, dockerLauncher); | ||
| } | ||
| private void assertEncoding(String description, String file, String charset, String expected, String expectedEncoding, boolean output, FilePath dockerWS, Launcher dockerLauncher) throws Exception { | ||
| BourneShellScript dt = new BourneShellScript("cat " + file); | ||
| if (charset != null) { | ||
| if (charset.isEmpty()) { | ||
| dt.defaultCharset(); | ||
| } else { | ||
| dt.charset(Charset.forName(charset)); | ||
| } | ||
| } | ||
| if (output) { | ||
| dt.captureOutput(); | ||
| } | ||
| Controller c = dt.launch(new EnvVars(), dockerWS, dockerLauncher, listener); | ||
| while (c.exitStatus(dockerWS, dockerLauncher, listener) == null) { | ||
| Thread.sleep(100); | ||
| } | ||
| assertEquals(description, 0, c.exitStatus(dockerWS, dockerLauncher, listener).intValue()); | ||
| if (output) { | ||
| c.writeLog(dockerWS, System.err); | ||
| assertEquals(description, expected, new String(c.getOutput(dockerWS, launcher), expectedEncoding)); | ||
| } else { | ||
| ByteArrayOutputStream baos = new ByteArrayOutputStream(); | ||
| c.writeLog(dockerWS, baos); | ||
| assertThat(description, baos.toString(expectedEncoding), containsString(expected)); | ||
| } | ||
| c.cleanup(dockerWS); | ||
|
|
||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This may be writing a number of bytes to
CountingOutputStreamwhich is different thantoRead(when the full write succeeds). Probably the transcoding needs to be done on the master side, which will be a little trickier since anydefaultCharsetcall needs to happen on the agent side.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the bug, if there is one, is rendered obsolete by #62 which renders this dead code.