001/*
002 * Copyright 2017-2022 original authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.micronaut.maven.services;
017
018import com.github.dockerjava.api.DockerClient;
019import com.github.dockerjava.api.async.ResultCallback;
020import com.github.dockerjava.api.command.BuildImageCmd;
021import com.github.dockerjava.api.command.BuildImageResultCallback;
022import com.github.dockerjava.api.command.CreateContainerCmd;
023import com.github.dockerjava.api.command.CreateContainerResponse;
024import com.github.dockerjava.api.command.ExecCreateCmd;
025import com.github.dockerjava.api.command.InspectImageCmd;
026import com.github.dockerjava.api.command.InspectImageResponse;
027import com.github.dockerjava.api.command.KillContainerCmd;
028import com.github.dockerjava.api.command.PushImageCmd;
029import com.github.dockerjava.api.command.RemoveContainerCmd;
030import com.github.dockerjava.api.command.RemoveImageCmd;
031import com.github.dockerjava.api.command.StartContainerCmd;
032import com.github.dockerjava.api.command.WaitContainerCmd;
033import com.github.dockerjava.api.command.WaitContainerResultCallback;
034import com.github.dockerjava.api.exception.DockerClientException;
035import com.github.dockerjava.api.exception.ConflictException;
036import com.github.dockerjava.api.exception.DockerException;
037import com.github.dockerjava.api.exception.NotFoundException;
038import com.github.dockerjava.api.model.AuthConfig;
039import com.github.dockerjava.api.model.AuthConfigurations;
040import com.github.dockerjava.api.model.AuthResponse;
041import com.github.dockerjava.api.model.Bind;
042import com.github.dockerjava.api.model.BuildResponseItem;
043import com.github.dockerjava.api.model.Frame;
044import com.github.dockerjava.api.model.HostConfig;
045import com.github.dockerjava.api.model.Info;
046import com.github.dockerjava.core.DefaultDockerClientConfig;
047import com.github.dockerjava.core.DockerClientConfig;
048import com.github.dockerjava.core.DockerClientImpl;
049import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
050import com.google.cloud.tools.jib.api.Credential;
051import io.micronaut.maven.DockerfileMojo;
052import io.micronaut.core.annotation.Internal;
053import io.micronaut.maven.jib.JibConfigurationService;
054import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
055import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
056import org.apache.commons.io.FileUtils;
057import org.apache.commons.io.IOUtils;
058import org.apache.commons.lang3.StringUtils;
059import org.apache.maven.project.MavenProject;
060import org.slf4j.Logger;
061import org.slf4j.LoggerFactory;
062import org.testcontainers.containers.output.FrameConsumerResultCallback;
063import org.testcontainers.containers.output.OutputFrame;
064import org.testcontainers.containers.output.Slf4jLogConsumer;
065import org.testcontainers.utility.DockerImageName;
066import org.testcontainers.utility.RegistryAuthLocator;
067
068import javax.inject.Inject;
069import javax.inject.Singleton;
070import java.io.File;
071import java.io.IOException;
072import java.io.InputStream;
073import java.nio.charset.StandardCharsets;
074import java.nio.file.Files;
075import java.nio.file.Path;
076import java.nio.file.StandardCopyOption;
077import java.util.ArrayList;
078import java.util.List;
079import java.util.Map;
080import java.util.Optional;
081import java.util.concurrent.TimeUnit;
082import java.util.function.Consumer;
083
084/**
085 * Provides methods to work with Docker images.
086 *
087 * @author Álvaro Sánchez-Mariscal
088 * @since 1.1
089 */
090@Singleton
091public class DockerService {
092
093    private static final Logger LOG = LoggerFactory.getLogger(DockerService.class);
094
095    private final DockerClientConfig config;
096    private final MavenProject mavenProject;
097    private final JibConfigurationService jibConfigurationService;
098    private DockerClient dockerClient;
099
100    @SuppressWarnings("CdiInjectionPointsInspection")
101    @Inject
102    public DockerService(MavenProject mavenProject, JibConfigurationService jibConfigurationService) {
103        this.mavenProject = mavenProject;
104        this.jibConfigurationService = jibConfigurationService;
105        this.config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
106    }
107
108    /**
109     * Uses the given Docker client instead of connecting to the configured Docker host. For tests.
110     *
111     * @param mavenProject the Maven project
112     * @param jibConfigurationService the Jib configuration service
113     * @param dockerClient the Docker client
114     */
115    DockerService(MavenProject mavenProject, JibConfigurationService jibConfigurationService, DockerClient dockerClient) {
116        this(mavenProject, jibConfigurationService);
117        this.dockerClient = dockerClient;
118    }
119
120    private DockerClient getDockerClient() {
121        if (dockerClient == null) {
122            var httpClient = new ZerodepDockerHttpClient.Builder()
123                    .dockerHost(config.getDockerHost())
124                    .sslConfig(config.getSSLConfig())
125                    .build();
126            dockerClient = DockerClientImpl.getInstance(config, httpClient);
127        }
128        return dockerClient;
129    }
130
131    /**
132     * @param dockerfileName the name of the Dockerfile to load
133     * @return the {@link BuildImageCmd} by loading the given Dockerfile as classpath resource.
134     */
135    public BuildImageCmd buildImageCmd(String dockerfileName) throws IOException {
136        verifyDockerRunning();
137        BuildImageCmd buildImageCmd = getDockerClient().buildImageCmd(loadDockerfileAsResource(dockerfileName));
138        maybeConfigureBuildAuth(buildImageCmd);
139        return buildImageCmd;
140    }
141
142    private void maybeConfigureBuildAuth(BuildImageCmd buildImageCmd) {
143        jibConfigurationService.getFromImage().ifPresent(image -> {
144            Optional<Credential> fromCredentials = jibConfigurationService.getFromCredentials();
145            Optional<Credential> credential = fromCredentials.or(() -> jibConfigurationService.resolveCredentialForImage(image, LOG));
146            credential.ifPresent(cred -> {
147                var username = cred.getUsername();
148                var password = cred.getPassword();
149                AuthConfig authConfig = getAuthConfigFor(image, username, password);
150                var authConfigurations = new AuthConfigurations();
151                authConfigurations.addConfig(authConfig);
152                buildImageCmd.withBuildAuthConfigs(authConfigurations);
153            });
154        });
155    }
156
157    /**
158     * @return a default {@link BuildImageCmd}.
159     */
160    public BuildImageCmd buildImageCmd() {
161        verifyDockerRunning();
162        BuildImageCmd buildImageCmd = getDockerClient().buildImageCmd();
163        maybeConfigureBuildAuth(buildImageCmd);
164        return buildImageCmd;
165    }
166
167    /**
168     * Builds the Docker image from the given {@link BuildImageCmd} builder.
169     *
170     * @param builder The builder to use.
171     * @return The resulting image ID.
172     */
173    public String buildImage(BuildImageCmd builder) {
174        verifyDockerRunning();
175        if (builder.getBuildArgs() != null) {
176            builder.getBuildArgs().forEach((k, v) -> LOG.info("Using {}: {}", k, v));
177        }
178        BuildImageResultCallback resultCallback = new BuildImageResultCallback() {
179            @Override
180            public void onNext(BuildResponseItem item) {
181                super.onNext(item);
182                if (item.isErrorIndicated() && item.getErrorDetail() != null) {
183                    LOG.error(item.getErrorDetail().getMessage());
184                } else if (item.getStream() != null) {
185                    String msg = StringUtils.removeEnd(item.getStream(), System.lineSeparator());
186                    LOG.info(msg);
187                }
188            }
189        };
190
191        return builder
192            .exec(resultCallback)
193            .awaitImageId();
194    }
195
196    /**
197     * Creates a container based on a given image, and runs it.
198     *
199     * @param imageId the image to use
200     * @param timeoutSeconds the timeout in seconds for the container to finish execution
201     * @param checkpointNetworkName the name of the network to use for the container
202     * @param binds the bind mounts to use
203     */
204    public void runPrivilegedImageAndWait(String imageId, Integer timeoutSeconds, String checkpointNetworkName, String... binds) throws IOException {
205        String containerId = createContainer(imageId, checkpointNetworkName, true, Map.of(), binds);
206        startAndWait(containerId, imageId, timeoutSeconds);
207    }
208
209    /**
210     * Creates an unprivileged container from the given image, without starting it.
211     *
212     * @param imageId the image to use
213     * @param networkName the name of the network to use for the container, if any
214     * @param environment the environment variables to set, on top of the image ones
215     * @return the container ID
216     * @since 5.1.0
217     */
218    @Internal
219    public String createContainer(String imageId, String networkName, Map<String, String> environment) {
220        return createContainer(imageId, networkName, false, environment);
221    }
222
223    private String createContainer(String imageId, String networkName, boolean privileged, Map<String, String> environment,
224                                   String... binds) {
225        verifyDockerRunning();
226        try (CreateContainerCmd create = getDockerClient().createContainerCmd(imageId)) {
227            HostConfig hostConfig = create.getHostConfig();
228            if (hostConfig == null) {
229                throw new DockerClientException("When setting binds and privileged, hostConfig was null.  Please check your docker installation and try again");
230            }
231            if (privileged) {
232                hostConfig.withPrivileged(true);
233            }
234            if (networkName != null) {
235                hostConfig.withNetworkMode(networkName);
236            }
237            for (String bind : binds) {
238                hostConfig.withBinds(Bind.parse(bind));
239            }
240            if (!environment.isEmpty()) {
241                create.withEnv(environment.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList());
242            }
243            return create.exec().getId();
244        }
245    }
246
247    /**
248     * Starts a container and waits for it to exit with status 0. Otherwise, logs its output and fails.
249     *
250     * @param containerId the container
251     * @param imageId the image of the container, for the error message
252     * @param timeoutSeconds the timeout in seconds for the container to finish execution
253     * @throws IOException if the container does not exit with status 0 within the timeout
254     * @since 5.1.0
255     */
256    @Internal
257    public void startAndWait(String containerId, String imageId, Integer timeoutSeconds) throws IOException {
258        startContainer(containerId);
259        LOG.info("Waiting {} seconds for completion", timeoutSeconds);
260        int exitCode;
261        try {
262            exitCode = awaitExit(containerId, timeoutSeconds);
263        } catch (IOException e) {
264            logContainerOutput(containerId);
265            throw e;
266        }
267        if (exitCode != 0) {
268            logContainerOutput(containerId);
269            throw new IOException("Image " + imageId + " exited with code " + exitCode);
270        }
271    }
272
273    /**
274     * Starts a container.
275     *
276     * @param containerId the container
277     * @since 5.1.0
278     */
279    @Internal
280    public void startContainer(String containerId) {
281        try (StartContainerCmd start = getDockerClient().startContainerCmd(containerId)) {
282            start.exec();
283            LOG.info("Container started: {}", containerId);
284        }
285    }
286
287    /**
288     * Waits for a container to exit.
289     *
290     * @param containerId the container
291     * @param timeoutSeconds the timeout in seconds
292     * @return the exit code of the container
293     * @throws IOException if the container does not exit within the timeout
294     * @since 5.1.0
295     */
296    @Internal
297    public int awaitExit(String containerId, int timeoutSeconds) throws IOException {
298        try (WaitContainerCmd wait = getDockerClient().waitContainerCmd(containerId)) {
299            WaitContainerResultCallback waitResult = wait.start();
300            try {
301                return waitResult.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
302            } catch (DockerClientException e) {
303                throw new IOException("Container " + containerId + " did not exit within " + timeoutSeconds + " seconds", e);
304            }
305        }
306    }
307
308    /**
309     * Sends a signal to the main process of a running container.
310     *
311     * @param containerId the container
312     * @param signal the signal, for example {@code SIGTERM}
313     * @since 5.1.0
314     */
315    @Internal
316    public void signalContainer(String containerId, String signal) {
317        try (KillContainerCmd kill = getDockerClient().killContainerCmd(containerId)) {
318            kill.withSignal(signal).exec();
319        } catch (ConflictException | NotFoundException e) {
320            LOG.debug("Container {} is not running: {}", containerId, e.getMessage());
321        }
322    }
323
324    /**
325     * Runs a command in a running container and waits for it.
326     *
327     * @param containerId the container
328     * @param timeoutSeconds the timeout in seconds
329     * @param output receives the output of the command, line by line
330     * @param command the command and its arguments
331     * @return the exit code of the command
332     * @throws IOException if the command does not finish within the timeout
333     * @since 5.1.0
334     */
335    @Internal
336    public int execInContainer(String containerId, int timeoutSeconds, Consumer<String> output, String... command) throws IOException {
337        String execId;
338        try (ExecCreateCmd create = getDockerClient().execCreateCmd(containerId)) {
339            execId = create.withCmd(command).withAttachStdout(true).withAttachStderr(true).exec().getId();
340        }
341        try (var callback = new LineCallback(output)) {
342            boolean completed = getDockerClient().execStartCmd(execId).exec(callback).awaitCompletion(timeoutSeconds, TimeUnit.SECONDS);
343            callback.flush();
344            if (!completed) {
345                throw new IOException("Command " + command[0] + " did not finish within " + timeoutSeconds + " seconds");
346            }
347        } catch (InterruptedException e) {
348            Thread.currentThread().interrupt();
349            throw new IOException("Interrupted while running " + command[0], e);
350        }
351        Long exitCode = getDockerClient().inspectExecCmd(execId).exec().getExitCodeLong();
352        return exitCode == null ? -1 : exitCode.intValue();
353    }
354
355    /**
356     * Runs an image with the given entrypoint, waits for it and returns its output.
357     *
358     * @param imageId the image to use
359     * @param timeoutSeconds the timeout in seconds
360     * @param entrypoint the entrypoint, which replaces the image entrypoint and command
361     * @return the exit code and the output of the container
362     * @throws IOException if the container does not exit within the timeout
363     * @since 5.1.0
364     */
365    @Internal
366    public ContainerOutput runAndCaptureOutput(String imageId, int timeoutSeconds, List<String> entrypoint) throws IOException {
367        verifyDockerRunning();
368        String containerId;
369        try (CreateContainerCmd create = getDockerClient().createContainerCmd(imageId)) {
370            containerId = create.withEntrypoint(entrypoint).exec().getId();
371        }
372        try {
373            startContainer(containerId);
374            int exitCode = awaitExit(containerId, timeoutSeconds);
375            var lines = new ArrayList<String>();
376            try (var callback = new LineCallback(lines::add)) {
377                getDockerClient().logContainerCmd(containerId).withStdOut(true).withStdErr(true).exec(callback).awaitCompletion();
378                callback.flush();
379            } catch (InterruptedException _) {
380                Thread.currentThread().interrupt();
381            }
382            return new ContainerOutput(exitCode, String.join("\n", lines));
383        } finally {
384            removeContainer(containerId);
385        }
386    }
387
388    /**
389     * Logs the output of a container.
390     *
391     * @param containerId the container
392     * @since 5.1.0
393     */
394    @Internal
395    public void logContainerOutput(String containerId) {
396        final Slf4jLogConsumer stdoutConsumer = new Slf4jLogConsumer(LOG);
397        final Slf4jLogConsumer stderrConsumer = new Slf4jLogConsumer(LOG);
398
399        try (var callback = new FrameConsumerResultCallback()) {
400            callback.addConsumer(OutputFrame.OutputType.STDOUT, stdoutConsumer);
401            callback.addConsumer(OutputFrame.OutputType.STDERR, stderrConsumer);
402
403            getDockerClient().logContainerCmd(containerId)
404                .withStdOut(true)
405                .withStdErr(true)
406                .exec(callback)
407                .awaitCompletion();
408        } catch (InterruptedException e) {
409            Thread.currentThread().interrupt();
410        } catch (IOException e) {
411            LOG.warn("Could not read the output of container {}: {}", containerId, e.getMessage());
412        }
413    }
414
415    /**
416     * Copies a regular file out of a container, which may have exited.
417     *
418     * @param containerId the container
419     * @param containerPath the absolute path of the file in the container
420     * @param target the file to write
421     * @throws IOException if the file cannot be copied
422     * @since 5.1.0
423     */
424    @Internal
425    public void copyFileFromContainer(String containerId, String containerPath, Path target) throws IOException {
426        try (InputStream archive = getDockerClient().copyArchiveFromContainerCmd(containerId, containerPath).exec();
427             var tar = new TarArchiveInputStream(archive)) {
428            TarArchiveEntry entry = tar.getNextEntry();
429            if (entry == null || !entry.isFile()) {
430                throw new IOException(containerPath + " is not a file in container " + containerId);
431            }
432            Files.createDirectories(target.toAbsolutePath().getParent());
433            Files.copy(tar, target, StandardCopyOption.REPLACE_EXISTING);
434        } catch (NotFoundException e) {
435            throw new IOException(containerPath + " does not exist in container " + containerId, e);
436        }
437    }
438
439    /**
440     * Removes a container, stopping it if it is running.
441     *
442     * @param containerId the container
443     * @since 5.1.0
444     */
445    @Internal
446    public void removeContainer(String containerId) {
447        try (RemoveContainerCmd remove = getDockerClient().removeContainerCmd(containerId)) {
448            remove.withForce(true).withRemoveVolumes(true).exec();
449        } catch (NotFoundException _) {
450            LOG.debug("Container {} was already removed", containerId);
451        }
452    }
453
454    /**
455     * Removes an image reference without force: a tag, or the ID of an image that at most one tag refers to. Docker
456     * deletes the image when no tag refers to it anymore, and fails instead of removing an image that other tags refer
457     * to or that a container uses.
458     *
459     * @param image the tag or the image ID
460     * @since 5.1.0
461     */
462    @Internal
463    public void removeImage(String image) {
464        try (RemoveImageCmd remove = getDockerClient().removeImageCmd(image)) {
465            remove.exec();
466        } catch (NotFoundException _) {
467            LOG.debug("Image {} was already removed", image);
468        }
469    }
470
471    /**
472     * @param image the image name or ID
473     * @return the image details
474     * @since 5.1.0
475     */
476    @Internal
477    public InspectImageResponse inspectImage(String image) {
478        verifyDockerRunning();
479        try (InspectImageCmd inspect = getDockerClient().inspectImageCmd(image)) {
480            return inspect.exec();
481        }
482    }
483
484    /**
485     * @return the platform of the Docker daemon, as {@code os/architecture} with Go architecture names such as
486     * {@code linux/amd64} or {@code linux/arm64}
487     * @throws IllegalStateException if the Docker daemon is not reachable
488     * @since 5.1.0
489     */
490    @Internal
491    public String getDaemonPlatform() {
492        verifyDockerRunning();
493        Info info = getDockerClient().infoCmd().exec();
494        return info.getOsType() + "/" + goArchitecture(info.getArchitecture());
495    }
496
497    /**
498     * @param architecture an architecture name, as the Docker daemon or Jib configuration gives it
499     * @return the Go architecture name used in image platforms, such as {@code amd64} or {@code arm64}
500     */
501    static String goArchitecture(String architecture) {
502        return switch (architecture) {
503            case "x86_64", "amd64" -> "amd64";
504            case "aarch64", "arm64" -> "arm64";
505            default -> architecture;
506        };
507    }
508
509    /**
510     * Copies a file from the specified container path in the given image ID, into a temporal location.
511     *
512     * @param imageId The image ID.
513     * @param containerPath The container path.
514     * @return The temporal file.
515     */
516    public File copyFromContainer(String imageId, String containerPath) {
517        CreateContainerCmd containerCmd = getDockerClient().createContainerCmd(imageId);
518        CreateContainerResponse container = containerCmd.exec();
519        getDockerClient().startContainerCmd(container.getId());
520        InputStream nativeImage = getDockerClient().copyArchiveFromContainerCmd(container.getId(), containerPath).exec();
521
522        try (var fin = new TarArchiveInputStream(nativeImage)) {
523            TarArchiveEntry tarEntry = fin.getNextEntry();
524            File file = new File(mavenProject.getBuild().getDirectory(), tarEntry.getName());
525            if (!file.getCanonicalFile().toPath().startsWith(mavenProject.getBuild().getDirectory())) {
526                throw new IOException("Entry is outside of the target directory");
527            }
528
529            IOUtils.copy(fin, Files.newOutputStream(file.toPath()));
530
531            return file;
532        } catch (IOException e) {
533            LOG.error("Failed to copy file from container", e);
534        } finally {
535            containerCmd.close();
536        }
537        return null;
538    }
539
540    /**
541     * Loads the given Dockerfile as classpath resource and copies it into a temporary location in the target directory.
542     *
543     * @param name the name of the Dockerfile.
544     * @return the file where the Dockerfile was copied to.
545     */
546    public File loadDockerfileAsResource(String name) throws IOException {
547        return loadDockerfileAsResource(name, DockerfileMojo.DOCKERFILE);
548    }
549
550    /**
551     * Loads the given Dockerfile as classpath resource and copies it into a temporary location in the target directory.
552     *
553     * @param name the name of the Dockerfile.
554     * @param targetFileName the name of the file to copy the Dockerfile to.
555     * @return the file where the Dockerfile was copied to.
556     */
557    public File loadDockerfileAsResource(String name, String targetFileName) throws IOException {
558        String path = "/dockerfiles/" + name;
559        InputStream stream = getClass().getResourceAsStream(path);
560        if (stream != null) {
561            var dockerfile = new File(mavenProject.getBuild().getDirectory(), targetFileName);
562            FileUtils.copyInputStreamToFile(stream, dockerfile);
563            return dockerfile;
564        }
565        return null;
566    }
567
568    /**
569     * @param imageName the image name
570     * @return a {@link PushImageCmd} from the given image name.
571     */
572    public PushImageCmd pushImageCmd(String imageName) {
573        verifyDockerRunning();
574        return getDockerClient().pushImageCmd(imageName);
575    }
576
577    /**
578     * @param dockerImage the image name
579     * @param username the username
580     * @param password the password
581     * @return an {@link AuthConfig} object for the given image, username and password.
582     */
583    public AuthConfig getAuthConfigFor(String dockerImage, String username, String password) {
584        DockerImageName dockerImageName = DockerImageName.parse(dockerImage);
585        var defaultAuthConfig = new AuthConfig()
586            .withRegistryAddress(dockerImageName.getRegistry())
587            .withUsername(username)
588            .withPassword(password);
589        RegistryAuthLocator registryAuthLocator = RegistryAuthLocator.instance();
590        AuthConfig authConfig = registryAuthLocator.lookupAuthConfig(dockerImageName, defaultAuthConfig);
591        boolean loginSucceeded = false;
592        try {
593            AuthResponse authResponse = getDockerClient().authCmd().withAuthConfig(authConfig).exec();
594            if (authResponse.getStatus() != null && authResponse.getStatus().equals("Login Succeeded")) {
595                loginSucceeded = true;
596            }
597        } catch (Exception ignored) {
598            // typically this is com.github.dockerjava.api.exception.UnauthorizedException
599        }
600
601        if (loginSucceeded) {
602            LOG.info("Successfully logged in to registry {}", dockerImageName.getRegistry());
603        } else {
604            LOG.warn("Failed to login to registry {}", dockerImageName.getRegistry());
605        }
606        return authConfig;
607    }
608
609    private void verifyDockerRunning() {
610        try {
611            getDockerClient().pingCmd().exec();
612        } catch (DockerException e) {
613            throw new IllegalStateException(e.getMessage());
614        } catch (RuntimeException e) {
615            throw new IllegalStateException("Cannot connect to the Docker daemon at " + config.getDockerHost() + ". Is the docker daemon running?", e);
616        }
617    }
618
619    /**
620     * The exit code and the output of a container.
621     *
622     * @param exitCode the exit code
623     * @param output the standard output and error, interleaved
624     * @since 5.1.0
625     */
626    @Internal
627    public record ContainerOutput(int exitCode, String output) {
628    }
629
630    /**
631     * Splits the frames of a container stream into lines.
632     */
633    private static final class LineCallback extends ResultCallback.Adapter<Frame> {
634        private final Consumer<String> lines;
635        private final StringBuilder pending = new StringBuilder();
636
637        private LineCallback(Consumer<String> lines) {
638            this.lines = lines;
639        }
640
641        @Override
642        public void onNext(Frame frame) {
643            pending.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
644            int newLine = pending.indexOf("\n");
645            while (newLine >= 0) {
646                lines.accept(StringUtils.removeEnd(pending.substring(0, newLine), "\r"));
647                pending.delete(0, newLine + 1);
648                newLine = pending.indexOf("\n");
649            }
650        }
651
652        private void flush() {
653            if (!pending.isEmpty()) {
654                lines.accept(pending.toString());
655                pending.setLength(0);
656            }
657        }
658    }
659}