All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.honton.chas.compose.maven.plugin.ComposeAssemble Maven / Gradle / Ivy

Go to download

Use Compose to run docker/podman images from Maven. Store compose configuration in Maven repository for use by downstream image consumers.

There is a newer version: 0.0.6
Show newest version
package org.honton.chas.compose.maven.plugin;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.SneakyThrows;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
import org.eclipse.aether.RepositoryException;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.honton.chas.compose.maven.plugin.ArtifactHelper.Coordinates;
import org.yaml.snakeyaml.Yaml;

/** Assemble compose configuration and attach as secondary artifact */
@Mojo(name = "assemble", defaultPhase = LifecyclePhase.COMPILE, threadSafe = true)
public class ComposeAssemble extends ComposeGoal {

  /** Attach compose configuration as a secondary artifact */
  @Parameter(property = "compose.attach", defaultValue = "true")
  boolean attach;

  /** Dependencies in `Group:Artifact:Version` or `Group:Artifact::Classifier:Version` form */
  @Parameter List dependencies;

  /**
   * Directory which holds compose application configuration(s). Compose files should be in
   * subdirectories to namespace the configuration.
   */
  @Parameter(property = "compose.source", defaultValue = "${project.basedir}/src/main/compose")
  String source;

  @Parameter(defaultValue = "${project}", required = true, readonly = true)
  MavenProject project;

  @Component RepositorySystem repoSystem;

  @Parameter(defaultValue = "${repositorySystemSession}", readonly = true)
  RepositorySystemSession repoSession;

  @Component MavenProjectHelper projectHelper;
  private ArtifactHelper artifactHelper;
  private Yaml yaml;
  private Map coordinatesToInfo;
  private Map serviceToCoordinates;

  protected final void doExecute() throws Exception {
    /*
     * Directory which holds compose application configuration(s). Compose files should be in
     * subdirectories to namespace the configuration.
     */
    Path composeSrcPath = Path.of(source);
    if (Files.isDirectory(composeSrcPath)) {
      yaml = new Yaml();
      artifactHelper = new ArtifactHelper(project, composeSrcPath, repoSystem, repoSession);
      coordinatesToInfo = new HashMap<>();
      serviceToCoordinates = new HashMap<>();

      ArtifactHelper.forEach(dependencies, this::addDependency);

      artifactHelper.processComposeSrc(getLog(), this::readComposeFile);
      artifactHelper.processComposeSrc(getLog(), this::writeComposeJar);
      if (!coordinatesToInfo.isEmpty()) {
        return;
      }
    } else {
      getLog().warn("Not a directory: " + composeSrcPath);
    }
    getLog().info("No compose files found");
  }

  @SneakyThrows
  private void addDependency(String dependency) {
    DefaultArtifact artifact = ArtifactHelper.composeArtifact(dependency);
    artifactHelper.addArtifact(artifact, this::addArtifact);
  }

  private void addArtifact(Coordinates nvp, File file)
      throws IOException, MojoExecutionException, RepositoryException {
    if (nvp.prior() != null) {
      throw new MojoExecutionException(nvp.gav() + " previously had version " + nvp.prior());
    }
    getLog().debug("adding dependency " + nvp.key());
    try (JarReader jr =
        new JarReader(file) {
          @Override
          void process() throws IOException {
            if (isManifestEntry()) {
              String[] services = extractMainAttributes(JarReader.SERVICES);
              for (String service : services) {
                serviceToCoordinates.put(service, nvp.gav());
              }
            }
          }
        }) {
      jr.visitEntries();
    }
  }

  void readComposeFile(String classifier, String namespace, Path composeYaml) throws IOException {
    String contents = Files.readString(composeYaml);
    Map model = yaml.load(contents);
    String coordinates = artifactHelper.coordinatesFromClassifier(classifier);
    List serviceInfos;
    if (model.get("services") instanceof Map services) {
      serviceInfos = readServices(coordinates, services);
    } else {
      serviceInfos = List.of();
    }
    coordinatesToInfo.put(
        coordinates, new ArtifactInfo(coordinates, composeYaml, contents, serviceInfos));
  }

  private List readServices(String coordinates, Map services) {
    List serviceInfos = new ArrayList<>();
    for (Map.Entry entries : services.entrySet()) {
      if (entries.getKey() instanceof String serviceName
          && entries.getValue() instanceof Map service) {

        if (!service.containsKey("image") && !service.containsKey("extends")) {
          // only services with image defined are considered
          // otherwise the service definition is going to augment the primary definition
          continue;
        }

        String priorCoordinates = serviceToCoordinates.put(serviceName, coordinates);
        if (priorCoordinates != null) {
          getLog()
              .error(
                  "Service "
                      + serviceName
                      + " defined in "
                      + coordinates
                      + ", previously defined in "
                      + priorCoordinates);
        }

        List dependsOn = new ArrayList<>();
        if (service.get("depends_on") instanceof List dependsOnList) {
          dependsOn.addAll((Collection) dependsOnList);
        } else if (service.get("depends_on") instanceof Map dependsOnMap) {
          dependsOn.addAll((Collection) dependsOnMap.keySet());
        }
        if (service.get("extends") instanceof Map serviceExtends
            && serviceExtends.get("service") instanceof String dependentService) {
          dependsOn.add(dependentService);
        }
        getLog().debug(serviceName + " depends on " + dependsOn);
        serviceInfos.add(new ServiceInfo(serviceName, dependsOn));
      }
    }
    return serviceInfos;
  }

  private void writeComposeJar(String classifier, String namespace, Path composeYaml)
      throws IOException {
    Path destPath = artifactHelper.jarPath(classifier);
    ArtifactInfo info = coordinatesToInfo.get(artifactHelper.coordinatesFromClassifier(classifier));
    try (JarOutputStream destination =
        new JarOutputStream(
            Files.newOutputStream(
                destPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING),
            createManifest(info))) {
      jarArtifact(info, destination, namespace, composeYaml.getParent());
    }
    if (attach) {
      getLog().debug("attaching " + destPath);
      projectHelper.attachArtifact(project, "jar", classifier, destPath.toFile());
    }
  }

  private Manifest createManifest(ArtifactInfo info) {
    Manifest manifest = new Manifest();
    Attributes mainAttributes = manifest.getMainAttributes();
    mainAttributes.put(Name.MANIFEST_VERSION, "1.0");
    mainAttributes.put(Name.CONTENT_TYPE, "Compose");
    mainAttributes.putValue("Created-By", "compose-maven-plugin");

    String services =
        info.serviceInfos.stream().map(ServiceInfo::serviceName).collect(Collectors.joining(","));
    if (!services.isEmpty()) {
      mainAttributes.putValue(JarReader.SERVICES, services);
    }

    String dependencyCommaList =
        info.serviceInfos.stream()
            .flatMap(si -> si.dependsOn.stream())
            .flatMap(this::serviceToCoordinates)
            .filter(c -> !c.equals(info.coordinates))
            .collect(Collectors.joining(","));
    if (!dependencyCommaList.isEmpty()) {
      mainAttributes.putValue(JarReader.DEPENDENCIES, dependencyCommaList);
    }
    return manifest;
  }

  private Stream serviceToCoordinates(String service) {
    String coordinates = serviceToCoordinates.get(service);
    if (coordinates == null) {
      getLog().warn("Service " + service + " not found");
    }
    return Stream.ofNullable(coordinates);
  }

  private void jarArtifact(
      ArtifactInfo info, JarOutputStream stream, String namespace, Path directory)
      throws IOException {
    try (DirectoryStream files = Files.newDirectoryStream(directory, Files::isRegularFile)) {
      for (Path path : files) {
        jarFile(info, stream, namespace, path);
      }
    }
  }

  private void jarFile(ArtifactInfo info, JarOutputStream stream, String namespace, Path path)
      throws IOException {
    JarEntry entry = new JarEntry(ArtifactHelper.namespacedPath(namespace, path));
    entry.setTime(path.toFile().lastModified());
    stream.putNextEntry(entry);
    if (path.equals(info.composePath)) {
      stream.write(info.composeSpec.getBytes(StandardCharsets.UTF_8));
    } else {
      Files.copy(path, stream);
    }
    stream.closeEntry();
  }

  record ArtifactInfo(
      String coordinates, Path composePath, String composeSpec, List serviceInfos) {}

  record ServiceInfo(String serviceName, List dependsOn) {}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy