RamdiskBuildExtension.java

/*-
 * #%L
 * io.earcam.maven.plugin.ramdisk
 * %%
 * Copyright (C) 2018 earcam
 * %%
 * SPDX-License-Identifier: (BSD-3-Clause OR EPL-1.0 OR Apache-2.0 OR MIT)
 *
 * You <b>must</b> choose to accept, in full - any individual or combination of
 * the following licenses:
 * <ul>
 * 	<li><a href="https://opensource.org/licenses/BSD-3-Clause">BSD-3-Clause</a></li>
 * 	<li><a href="https://www.eclipse.org/legal/epl-v10.html">EPL-1.0</a></li>
 * 	<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache-2.0</a></li>
 * 	<li><a href="https://opensource.org/licenses/MIT">MIT</a></li>
 * </ul>
 * #L%
 */
package io.earcam.maven.plugin.ramdisk;

import static io.earcam.maven.plugin.ramdisk.RamdiskBuildExtension.NAME;
import static io.earcam.maven.plugin.ramdisk.RamdiskMojo.PROPERTY_DIRECTORY;
import static io.earcam.maven.plugin.ramdisk.RamdiskMojo.PROPERTY_SKIP;
import static java.nio.charset.Charset.defaultCharset;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Scanner;

import org.apache.maven.AbstractMavenLifecycleParticipant;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Plugin;
import org.apache.maven.model.PluginExecution;
import org.apache.maven.model.Scm;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.earcam.unexceptional.Exceptional;
import io.earcam.utilitarian.io.IoStreams;
import io.earcam.utilitarian.io.file.RecursiveFiles;

@Component(role = AbstractMavenLifecycleParticipant.class, hint = NAME, instantiationStrategy = "singleton")
public class RamdiskBuildExtension extends AbstractMavenLifecycleParticipant {

	private static final String TMPFS_NOT_FOUND = "Could not determine tmpfs directory to use for target";
	private static final Logger LOG = LoggerFactory.getLogger(RamdiskBuildExtension.class);

	static final String NAME = "ramdisk";
	static final String LOG_CATEGORY = "[ramdisk-extension]";

	Path tmpFsRoot;
	private Plugin plugin;


	@Override
	public void afterProjectsRead(MavenSession session)
	{
		Properties userProperties = session.getUserProperties();
		if(shouldSkip(userProperties, LOG_CATEGORY)) {
			return;
		}
		userProperties.put("clean.followSymLinks", "true");
		userProperties.put("maven.clean.followSymLinks", "true");

		tmpFsRoot = selectTmpFs(session.getCurrentProject());
		if(tmpFsRoot == null) {
			LOG.warn("{} {}", LOG_CATEGORY, TMPFS_NOT_FOUND);
			return;
		}
		List<MavenProject> modules = session.getAllProjects();
		LOG.debug("{} Applying ramdisk for: {}", LOG_CATEGORY, modules);

		plugin = createPlugin();

		modules.forEach(this::createTmpFsBuildDir);
	}


	static boolean shouldSkip(Properties properties, String logCategory)
	{
		boolean skip = "true".equals(properties.getOrDefault(PROPERTY_SKIP, "false"));
		if(skip) {
			LOG.debug("{} configured to skip execution", logCategory);
		}
		return skip;
	}


	protected Path selectTmpFs(MavenProject project)
	{
		return tmpFsFor(project);
	}


	static Path tmpFsFor(MavenProject project)
	{
		Optional<Path> fromProperty = fromProperty(project.getProperties());
		return fromProperty.orElse(findTmpFs());
	}


	private static Optional<Path> fromProperty(Properties properties)
	{
		return Optional.ofNullable(properties.getProperty(PROPERTY_DIRECTORY, System.getProperty(PROPERTY_DIRECTORY)))
				.map(File::new)
				.map(File::toPath);
	}


	static Path findTmpFs()
	{
		String uid = extractUid();

		List<Path> possibilities = new ArrayList<>();
		if(uid != null) {
			possibilities.add(Paths.get("/", "run", uid));
			possibilities.add(Paths.get("/", "run", "user", uid));
			possibilities.add(Paths.get("/", "var", "run", "user", uid));
		}
		possibilities.add(Paths.get("/", "dev", "shm"));
		possibilities.add(Paths.get("/", "tmp"));

		return possibilities.stream()
				.sequential()
				.filter(Files::isWritable)
				.findFirst()
				.orElse(null);
	}


	private static String extractUid()
	{
		try {
			Process process = new ProcessBuilder("/usr/bin/id", "-u", System.getProperty("user.name")).redirectErrorStream(true).start();

			try(Scanner scanner = new Scanner(process.getInputStream(), defaultCharset().toString())) {
				return Integer.toString(scanner.nextInt());
			}
		} catch(Exception e) {
			LOG.debug("{} Unable to get UID", LOG_CATEGORY, e);
			return null;
		}
	}


	private Plugin createPlugin()
	{
		String scrapeDeets = "META-INF/maven/io.earcam.maven.plugin/io.earcam.maven.plugin.ramdisk/plugin-help.xml";
		String xml = new String(IoStreams.readAllBytes(getClass().getClassLoader().getResourceAsStream(scrapeDeets)), UTF_8);

		Plugin plugin = new Plugin();
		plugin.setGroupId(extractFromXml(xml, "<groupId>", "</groupId>"));
		plugin.setArtifactId(extractFromXml(xml, "<artifactId>", "</artifactId>"));
		plugin.setVersion(extractFromXml(xml, "<version>", "</version>"));

		PluginExecution exec = new PluginExecution();
		exec.setId("post-clean");
		exec.setGoals(Collections.singletonList("ramdisk"));
		exec.setPhase("post-clean");
		plugin.addExecution(exec);

		exec = new PluginExecution();
		exec.setId("validate");
		exec.setGoals(Collections.singletonList("ramdisk"));
		exec.setPhase("validate");
		plugin.addExecution(exec);
		return plugin;
	}


	private String extractFromXml(String xml, String openTag, String closeTag)
	{
		int start = xml.indexOf(openTag);
		int end = xml.indexOf(closeTag);
		return xml.substring(start + openTag.length(), end);
	}


	private void createTmpFsBuildDir(MavenProject project)
	{
		if(shouldSkip(project.getProperties(), LOG_CATEGORY)) {
			return;
		}
		project.getBuild().addPlugin(plugin);
		Exceptional.accept(RamdiskBuildExtension::createTmpFsBuildDir, project, tmpFsRoot);
	}


	static void createTmpFsBuildDir(MavenProject project, Path tmpFs) throws IOException
	{
		Objects.requireNonNull(tmpFs, TMPFS_NOT_FOUND);

		Path linkTarget = tmpFs.resolve(relativePathFor(project));

		if(!linkTarget.toFile().exists()) {
			LOG.debug("link target does not exist, creating: {}", linkTarget);
			Files.createDirectories(linkTarget);
		}

		Path link = Paths.get(project.getBuild().getDirectory());

		if(link.toFile().exists() && !Files.isSymbolicLink(link)) {
			LOG.debug("{} local target directory exists but is not a symbolic link, moving contents: {}", LOG_CATEGORY, link);
			RecursiveFiles.move(link, linkTarget, REPLACE_EXISTING);

			// TODO what-if: link exists but points somewhere else ...
			// use-case, someone switches branches
			// we'll need to check: (!Files.readSymbolicLink(linkSource).equals(linkTarget.toAbsolutePath()))
		}
		if(!link.toFile().exists()) {
			LOG.debug("{} link does not exist, creating: {}", LOG_CATEGORY, link);
			Files.createSymbolicLink(link, linkTarget);
		}
	}


	static Path relativePathFor(MavenProject project)
	{
		Path linkTarget = Paths.get("maven", project.getGroupId(), project.getArtifactId(), project.getVersion());
		return appendScmTag(project, linkTarget);
	}


	private static Path appendScmTag(MavenProject project, Path linkTarget)
	{
		Scm scm = project.getScm();
		if(scm != null) {
			String tag = scm.getTag();
			if(tag != null && !tag.isEmpty()) {
				linkTarget = linkTarget.resolve(tag);
			}
		}
		return linkTarget;
	}


	/**
	 * This ensures that softlinks are recreated to avoid things like IDEs (that
	 * may be oblivious to extensions) creating "target" directories after a clean.
	 */
	@Override
	public void afterSessionEnd(MavenSession session)
	{
		session.getAllProjects().forEach(this::createTmpFsBuildDir);
	}
}