diff --git a/container/gradle.properties b/container/gradle.properties --- a/container/gradle.properties +++ b/container/gradle.properties @@ -1,2 +1,2 @@ group=org.implab.gradle -version=1.1.2 \ No newline at end of file +version=1.2.0 \ No newline at end of file diff --git a/container/src/main/java/org/implab/gradle/containers/ComposeExtension.java b/container/src/main/java/org/implab/gradle/containers/ComposeExtension.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/ComposeExtension.java @@ -0,0 +1,17 @@ +package org.implab.gradle.containers; + +import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; + +public abstract class ComposeExtension { + public final String COMPOSE_FILE = "compose.yaml"; + + public abstract SetProperty getProfiles(); + + public abstract Property getComposeFileName(); + + public ComposeExtension() { + getComposeFileName().convention(COMPOSE_FILE); + } + +} diff --git a/container/src/main/java/org/implab/gradle/containers/ComposePlugin.java b/container/src/main/java/org/implab/gradle/containers/ComposePlugin.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/ComposePlugin.java @@ -0,0 +1,159 @@ +package org.implab.gradle.containers; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.Configuration.State; +import org.gradle.api.logging.Logger; +import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.Delete; +import org.implab.gradle.containers.cli.Utils; +import org.implab.gradle.containers.tasks.ComposeRm; +import org.implab.gradle.containers.tasks.ComposeStop; +import org.implab.gradle.containers.tasks.ComposeUp; +import org.implab.gradle.containers.tasks.WriteEnv; + +public abstract class ComposePlugin implements Plugin, ProjectMixin { + public final String COMPOSE_IMAGES_CONFIGURATION = "composeImages"; + + public final String COMPOSE_EXTENSION = "compose"; + + public final String COMPOSE_UP_TASK = "up"; + + public final String COMPOSE_STOP_TASK = "stop"; + + public final String COMPOSE_RM_TASK = "rm"; + + public final String CLEAN_TASK = "clean"; + + public final String BUILD_TASK = "build"; + + public final String PROCESS_RESOURCES_TASK = "processResources"; + + public final String WRITE_ENV_TASK = "writeEnv"; + + public final String COMPOSE_VAR = "composeVar"; + + public final String ENV_FILE_NAME = ".env"; + + public Logger getLogger() { + return getProject().getLogger(); + } + + @Override + public void apply(Project project) { + var containerImages = configuration(COMPOSE_IMAGES_CONFIGURATION, Configurations.RESOLVABLE); + + // basic configuration, register extension + var basePlugin = plugin(ContainerBasePlugin.class); + var containerExtension = basePlugin.getContainerExtension(); + + var composeExtension = extension(COMPOSE_EXTENSION, ComposeExtension.class); + + var composeFile = containerExtension.getContextDirectory() + .file(composeExtension.getComposeFileName()); + var composeProfiles = composeExtension.getProfiles(); + + var cleanTask = task(CLEAN_TASK, Delete.class, t -> { + t.delete(containerExtension.getContextDirectory()); + }); + + // copy task from src/main + var processResources = task(PROCESS_RESOURCES_TASK, Copy.class, t -> { + t.mustRunAfter(cleanTask); + t.from(projectDirectory().dir("src/main")); + t.into(containerExtension.getContextDirectory()); + }); + + // write .env + var writeEnvTask = task(WRITE_ENV_TASK, WriteEnv.class, t -> { + t.dependsOn(processResources, containerImages); + t.getEnvFile().set(containerExtension.getContextDirectory().file(ENV_FILE_NAME)); + + t.getEnvironment().putAll(containerImages.map(this::extractComposeEnv)); + + }); + + var buildTask = task(BUILD_TASK, DefaultTask.class, t -> { + t.dependsOn(writeEnvTask); + }); + + var stopTask = task(COMPOSE_STOP_TASK, ComposeStop.class, t -> { + // stop must run after build + t.mustRunAfter(buildTask); + + t.getProfiles().addAll(composeProfiles); + t.getComposeFile().set(composeFile); + }); + + var rmTask = task(COMPOSE_RM_TASK, ComposeRm.class, t -> { + // rm must run after build and stop + t.mustRunAfter(buildTask, stopTask); + + t.getProfiles().addAll(composeProfiles); + t.getComposeFile().set(composeFile); + }); + + task(COMPOSE_UP_TASK, ComposeUp.class, t -> { + t.dependsOn(buildTask); + // up must run after stop and rm + t.mustRunAfter(stopTask, rmTask); + + t.getProfiles().addAll(composeProfiles); + t.getComposeFile().set(composeFile); + }); + } + + /** + * Processed the configurations, extracts composeVar extra property from + * each dependency in this configuration and adds a value to the resulting + * map. The values in the nap will contain image tags. + */ + private Map extractComposeEnv(Configuration config) { + if (config.getState() != State.UNRESOLVED) { + getLogger().error("extractComposeEnv: The configuration {} isn't resolved.", config.getName()); + throw new IllegalStateException("The specified configuration isn't resolved"); + } + + getLogger().info("extractComposeEnv {}", config.getName()); + + var map = new HashMap(); + + for (var dependency : config.getDependencies()) { + // get extra composeVar if present + extra(dependency, COMPOSE_VAR, String.class).optional().ifPresent(varName -> { + // if we have a composeVar extra attribute on this dependency + + // get files for the dependency + var files = config.files(dependency); + if (files.size() == 1) { + // should bw exactly 1 file + var file = files.stream().findAny().get(); + getLogger().info("Processing {}: {}", dependency, file); + + try { + // try read imageRef + Utils.readImageRef(file).getTag() + .ifPresentOrElse( + tag -> map.put(varName, tag), + () -> getLogger().error("ImageRef doesn't have a tag: {}", file)); + + } catch (IOException e) { + getLogger().error("Failed to read ImageRef {}: {}", file, e); + } + + } else { + getLogger().warn("Dependency {} must have exactly 1 file", dependency); + } + }); + } + + return map; + } + +} diff --git a/container/src/main/java/org/implab/gradle/containers/Configurations.java b/container/src/main/java/org/implab/gradle/containers/Configurations.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/Configurations.java @@ -0,0 +1,24 @@ +package org.implab.gradle.containers; + +import org.gradle.api.Action; +import org.gradle.api.artifacts.Configuration; + +public final class Configurations { + + public static final Action RESOLVABLE = Configurations::resolvable; + + public static final Action CONSUMABLE = Configurations::consumable; + + private Configurations() { + } + + private static void resolvable(Configuration configuration) { + configuration.setCanBeResolved(true); + configuration.setCanBeConsumed(false); + } + + private static void consumable(Configuration configuration) { + configuration.setCanBeResolved(false); + configuration.setCanBeConsumed(true); + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/ContainerBasePlugin.java b/container/src/main/java/org/implab/gradle/containers/ContainerBasePlugin.java --- a/container/src/main/java/org/implab/gradle/containers/ContainerBasePlugin.java +++ b/container/src/main/java/org/implab/gradle/containers/ContainerBasePlugin.java @@ -31,6 +31,7 @@ public class ContainerBasePlugin impleme containerExtension = project.getObjects().newInstance(ContainerExtension.class); + // TODO: move properties initialization into the constructor containerExtension.getImageAuthority() .convention(project.provider(() -> (String) project.getProperties().get("imagesAuthority"))); diff --git a/container/src/main/java/org/implab/gradle/containers/ProjectMixin.java b/container/src/main/java/org/implab/gradle/containers/ProjectMixin.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/ProjectMixin.java @@ -0,0 +1,69 @@ +package org.implab.gradle.containers; + +import java.util.Collections; +import java.util.Optional; + +import javax.inject.Inject; + +import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.file.Directory; +import org.gradle.api.tasks.TaskProvider; +import org.implab.gradle.containers.dsl.ExtraProps; +import org.implab.gradle.containers.dsl.MapEntry; + +/** Project configuration traits */ +public interface ProjectMixin { + @Inject + Project getProject(); + + /** registers the new task */ + default TaskProvider task(String name, Class clazz, Action configure) { + return getProject().getTasks().register(name, clazz, configure); + } + + /** Registers the new configuration */ + default NamedDomainObjectProvider configuration(String name, Action configure) { + return getProject().getConfigurations().register(name, configure); + } + + /** Returns the project directory */ + default Directory projectDirectory() { + return getProject().getLayout().getProjectDirectory(); + } + + /** Applies and returns the specified plugin, plugin is applied only once. */ + default > T plugin(Class clazz) { + getProject().getPluginManager().apply(clazz); + return getProject().getPlugins().findPlugin(clazz); + } + + /** Creates and register a new project extension. + * + * @param The type of the extension + * @param extensionName The name of the extension in the project + * @param clazz The class of the extension + * @return the newly created extension + */ + default T extension(String extensionName, Class clazz) { + T extension = getProject().getObjects().newInstance(clazz); + getProject().getExtensions().add(extensionName, extension); + return extension; + } + + /** Return extra properties container for the specified object */ + default Optional extra(Object target) { + return ExtraProps.extra(target); + } + + /** Returns accessor for the specified extra property name */ + default MapEntry extra(Object target, String prop, Class clazz) { + return ExtraProps.extra(target) + .map(x -> x.prop(prop, clazz)) + .orElseGet(() -> new MapEntry(Collections.emptyMap(), prop, clazz)); + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/cli/DockerTraits.java b/container/src/main/java/org/implab/gradle/containers/cli/DockerTraits.java --- a/container/src/main/java/org/implab/gradle/containers/cli/DockerTraits.java +++ b/container/src/main/java/org/implab/gradle/containers/cli/DockerTraits.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.Set; import org.gradle.api.logging.Logger; @@ -18,6 +19,10 @@ public abstract class DockerTraits { public final String INSPECT_COMMAND = "inspect"; public final String IMAGE_COMMAND = "image"; public final String TAG_COMMAND = "tag"; + public final String COMPOSE_COMMAND = "compose"; + public final String UP_COMMAND = "up"; + public final String STOP_COMMAND = "stop"; + public final String RM_COMMAND = "rm"; public abstract Logger getLogger(); @@ -138,4 +143,44 @@ public abstract class DockerTraits { } return false; } + + List composeArgs(File primaryCompose, Set profiles, String... extra) { + var args = new ArrayList(); + + args.add(COMPOSE_COMMAND); + args.add("--file"); + args.add(primaryCompose.getAbsolutePath()); + + if (profiles.size() > 0) { + for (var profile : profiles) { + args.add("--profile"); + args.add(profile); + } + } + + args.addAll(List.of(extra)); + + return args; + } + + public void composeUp(File primaryCompose, Set profiles) throws InterruptedException, IOException { + var args = composeArgs(primaryCompose, profiles, UP_COMMAND, "--detach"); + + complete(startProcess(builder(args))); + } + + public void composeStop(File primaryCompose, Set profiles) throws InterruptedException, IOException { + var args = composeArgs(primaryCompose, profiles, STOP_COMMAND); + complete(startProcess(builder(args))); + } + + public void composeRm(File primaryCompose, Set profiles, boolean removeVolumes) + throws InterruptedException, IOException { + var args = composeArgs(primaryCompose, profiles, RM_COMMAND, "--force", "--stop"); + + if (removeVolumes) + args.add("--volumes"); + + complete(startProcess(builder(args))); + } } diff --git a/container/src/main/java/org/implab/gradle/containers/dsl/ExtraProps.java b/container/src/main/java/org/implab/gradle/containers/dsl/ExtraProps.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/dsl/ExtraProps.java @@ -0,0 +1,42 @@ +package org.implab.gradle.containers.dsl; + +import java.util.Optional; + +import org.codehaus.groovy.runtime.DefaultGroovyMethods; +import org.gradle.api.plugins.ExtensionAware; +import org.gradle.api.plugins.ExtraPropertiesExtension; + +public class ExtraProps { + private final ExtraPropertiesExtension extraProps; + + protected ExtraProps(ExtraPropertiesExtension extraProps) { + this.extraProps = extraProps; + } + + public Optional get(String key, Class clazz) { + if (!extraProps.has(key)) + return Optional.empty(); + + return Optional.ofNullable(DefaultGroovyMethods.asType(extraProps.get(key), clazz)); + } + + public void put(String key, T value) { + extraProps.set(key, value); + } + + public void delete(String key) { + extraProps.getProperties().remove(key); + } + + public MapEntry prop(String key, Class clazz) { + return new MapEntry<>(extraProps.getProperties(), key, clazz); + } + + public static Optional extra(Object target) { + return target instanceof ExtensionAware + ? Optional.of(new ExtraProps( + ((ExtensionAware) target).getExtensions().getExtraProperties())) + : Optional.empty(); + + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/dsl/MapEntry.java b/container/src/main/java/org/implab/gradle/containers/dsl/MapEntry.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/dsl/MapEntry.java @@ -0,0 +1,39 @@ +package org.implab.gradle.containers.dsl; + +import java.util.Map; +import java.util.Optional; + +public class MapEntry { + private final Map map; + private final String key; + private final Class clazz; + + public MapEntry(Map map, String key, Class clazz) { + this.map = map; + this.key = key; + this.clazz = clazz; + } + + public boolean has() { + var value = map.get(key); + return value != null; + } + + public T get() { + if (!has()) + throw new IllegalStateException(); + return clazz.cast(map.get(key)); + } + + public void put(T value) { + map.put(key, value); + } + + public void remove() { + map.remove(key); + } + + public Optional optional() { + return has() ? Optional.of(get()) : Optional.empty(); + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/ComposeRm.java b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeRm.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeRm.java @@ -0,0 +1,22 @@ +package org.implab.gradle.containers.tasks; + +import java.io.IOException; + +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; + +public abstract class ComposeRm extends ComposeStop { + + @Internal + public abstract Property getRemoveVolumes(); + + public ComposeRm() { + getRemoveVolumes().convention(true); + } + + @TaskAction + public void run() throws InterruptedException, IOException { + docker().composeRm(getComposeFile().get().getAsFile(), getProfiles().get(), getRemoveVolumes().get()); + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/ComposeStop.java b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeStop.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeStop.java @@ -0,0 +1,13 @@ +package org.implab.gradle.containers.tasks; + +import java.io.IOException; + +import org.gradle.api.tasks.TaskAction; + +public abstract class ComposeStop extends ComposeTask { + + @TaskAction + public void run() throws InterruptedException, IOException { + docker().composeStop(getComposeFile().getAsFile().get(), getProfiles().get()); + } +} \ No newline at end of file diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/ComposeTask.java b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeTask.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeTask.java @@ -0,0 +1,33 @@ +package org.implab.gradle.containers.tasks; + +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Internal; + +/** + * Base task for compose subtasks like 'uo', 'rm', 'stop', etc. + */ +public abstract class ComposeTask extends DockerCliTask { + + /** The list of profiles to use with the compose commands */ + @Internal + public abstract SetProperty getProfiles(); + + /** + * The primary compose files. This task is executed regardless whether these + * files was changed. + */ + @Internal + public abstract RegularFileProperty getComposeFile(); + + protected ComposeTask() { + // the task can only be evaluated if the compose file is present + setOnlyIf(self -> { + var composeFile = getComposeFile().get().getAsFile(); + var exists = composeFile.exists(); + getLogger().info("file: {} {}", composeFile.toString(), exists ? "exists" : "doesn't exist"); + return exists; + }); + } + +} diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/ComposeUp.java b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeUp.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/tasks/ComposeUp.java @@ -0,0 +1,13 @@ +package org.implab.gradle.containers.tasks; + +import java.io.IOException; + +import org.gradle.api.tasks.TaskAction; + +public abstract class ComposeUp extends ComposeTask { + + @TaskAction + public void run() throws InterruptedException, IOException { + docker().composeUp(getComposeFile().getAsFile().get(), getProfiles().get()); + } +} diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/DockerCliTask.java b/container/src/main/java/org/implab/gradle/containers/tasks/DockerCliTask.java --- a/container/src/main/java/org/implab/gradle/containers/tasks/DockerCliTask.java +++ b/container/src/main/java/org/implab/gradle/containers/tasks/DockerCliTask.java @@ -4,8 +4,6 @@ import java.io.File; import java.util.Optional; import org.gradle.api.DefaultTask; -import org.gradle.api.file.Directory; -import org.gradle.api.file.DirectoryProperty; import org.gradle.api.logging.Logger; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; @@ -19,9 +17,12 @@ public abstract class DockerCliTask exte @Input public abstract Property getCliCmd(); + /** + * Returns working directory for docker commands + */ @Input @org.gradle.api.tasks.Optional - public abstract DirectoryProperty getWorkingDirectory(); + public abstract Property getWorkingDirectory(); @Internal protected ContainerExtension getContainerExtension() { @@ -47,7 +48,6 @@ public abstract class DockerCliTask exte @Override public Optional getWorkingDir() { return getWorkingDirectory() - .map(Directory::getAsFile) .map(Optional::of) .getOrElse(Optional.empty()); } diff --git a/container/src/main/java/org/implab/gradle/containers/tasks/WriteEnv.java b/container/src/main/java/org/implab/gradle/containers/tasks/WriteEnv.java new file mode 100644 --- /dev/null +++ b/container/src/main/java/org/implab/gradle/containers/tasks/WriteEnv.java @@ -0,0 +1,59 @@ +package org.implab.gradle.containers.tasks; + +import org.gradle.api.Action; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.implab.gradle.containers.PropertiesMixin; +import org.implab.gradle.containers.cli.Utils; +import org.implab.gradle.containers.dsl.MapPropertyEntry; + +import java.io.FileWriter; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import groovy.lang.Closure; + +public abstract class WriteEnv extends DefaultTask implements PropertiesMixin { + + @Input + public abstract MapProperty getEnvironment(); + + @OutputFile + public abstract RegularFileProperty getEnvFile(); + + public void env(Action> spec) { + getEnvironment().putAll(provider(() -> { + Map args = new HashMap<>(); + spec.execute(args); + getLogger().info("add buildArgs {}", args); + return args; + })); + } + + public void env(Closure> spec) { + env(Utils.wrapClosure(spec)); + } + + public MapPropertyEntry env(String key) { + return new MapPropertyEntry(getEnvironment(), key, getProviders()); + } + + @TaskAction + public void run() throws IOException { + try (var writer = new FileWriter(getEnvFile().get().getAsFile())) { + for (var entry : getEnvironment().get().entrySet()) { + writer.write(entry.getKey()); + writer.write("="); + writer.write(entry.getValue().replace("\n", "\\n")); + writer.write("\n"); + } + } + } + + +}