001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.distribution; 020 021import com.google.common.flogger.FluentLogger; 022import java.nio.file.Path; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.List; 026import java.util.Objects; 027import java.util.function.Consumer; 028import java.util.function.Supplier; 029import java.util.stream.Stream; 030import org.jdrupes.builder.api.Cleanliness; 031import org.jdrupes.builder.api.ConfigurationException; 032import static org.jdrupes.builder.api.CoreProperties.*; 033import org.jdrupes.builder.api.FileResource; 034import org.jdrupes.builder.api.Intent; 035import static org.jdrupes.builder.api.Intent.*; 036import org.jdrupes.builder.api.Project; 037import org.jdrupes.builder.api.Resource; 038import org.jdrupes.builder.api.ResourceProvider; 039import org.jdrupes.builder.api.ResourceProviderSpi; 040import org.jdrupes.builder.api.ResourceRequest; 041import org.jdrupes.builder.api.ResourceRetriever; 042import static org.jdrupes.builder.api.ResourceType.*; 043import org.jdrupes.builder.api.Resources; 044import org.jdrupes.builder.api.TarFile; 045import org.jdrupes.builder.api.ZipFile; 046import org.jdrupes.builder.core.AbstractGenerator; 047import org.jdrupes.builder.core.StreamCollector; 048import static org.jdrupes.builder.distribution.DistributionTypes.*; 049import org.jdrupes.builder.distribution.internal.ApplicationConfigurationData; 050import org.jdrupes.builder.distribution.internal.TarDistributionBuilder; 051import org.jdrupes.builder.distribution.internal.ZipDistributionBuilder; 052import org.jdrupes.builder.java.ClasspathElement; 053import static org.jdrupes.builder.java.JavaTypes.*; 054import org.jdrupes.builder.java.LibraryJarFile; 055import org.jdrupes.builder.mvnrepo.MvnRepoJarFile; 056import org.jdrupes.builder.mvnrepo.MvnRepoLibraryJarFile; 057import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 058import org.jdrupes.builder.mvnrepo.MvnRepoResource; 059import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 060 061/// The [ApplicationBuilder] generates application distributions as 062/// resources of type [ApplicationZipFile] or [ApplicationTarFile]. 063/// 064/// Both resource types represent runnable application distributions 065/// consisting of classpath resources and a generated start script 066/// that launches the application. 067/// 068/// The application can be configured using methods that control: 069/// 070/// * the [output directory][#destination(Path)] for the generated 071/// distribution, 072/// * the [base name][#distributionBaseName(Supplier)] of the generated 073/// archive file, 074/// * the executable (start script) [name][#executableName(String)], 075/// * the [main class][#mainClassName(String)] to execute (mandatory), 076/// * and the [JVM options][#applicationJvmOpts(Consumer)] required 077/// by the application and included in the generated start script. 078/// 079/// Method [#add(Stream)] is used to specify the classpath resources to 080/// be included in the generated distribution and added to the 081/// classpath when running the application. In addition, the application 082/// builder adds the resources obtained from the providers specified 083/// with [#addFrom], using a request for resources of type [LibraryJarFile] 084/// with all [intents][Intent]. 085/// 086/// Special handling is provided for resources of type [MvnRepoJarFile]. 087/// For these resources the associated [MvnRepoResource] information is 088/// collected first. The collected coordinates are then used to resolve 089/// the corresponding jar files from the Maven repository. The resolved 090/// JAR files are then added to the generated distribution. This prevents 091/// different versions of the same library to be included in the 092/// distribution. 093/// 094/// A request for [Cleanliness] removes any generated distribution 095/// archives from the configured destination directory. 096/// 097public class ApplicationBuilder extends AbstractGenerator 098 implements ResourceRetriever { 099 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 100 private Supplier<Path> destination 101 = () -> project().buildDirectory().resolve("distributions"); 102 private Supplier<String> distributionBaseName 103 = () -> project().name() + "-" + project().get(Version); 104 private final StreamCollector<ClasspathElement> resourceStreams 105 = StreamCollector.cached(); 106 private final StreamCollector<ResourceProvider> providers 107 = StreamCollector.uncached(); 108 private boolean providersProcessed; 109 private final ApplicationConfigurationData config 110 = new ApplicationConfigurationData(); 111 112 /// Initializes a new application builder. 113 /// 114 /// @param project the project 115 /// 116 public ApplicationBuilder(Project project) { 117 super(Objects.requireNonNull(project)); 118 config.executableName(project().name()); 119 } 120 121 @Override 122 public ApplicationBuilder name(String name) { 123 rename(name); 124 return this; 125 } 126 127 /// Returns the name of the script that starts the application. 128 /// The script for Windows has `.bat` appended to this name. 129 /// 130 /// @return the string 131 /// 132 public String executableName() { 133 return config.executableName(); 134 } 135 136 /// Sets the executable name. 137 /// 138 /// @param name the name 139 /// @return the application builder 140 /// 141 public ApplicationBuilder executableName(String name) { 142 config.executableName(name); 143 return this; 144 } 145 146 /// Returns the destination directory. Defaults to sub directory 147 /// `applications` in the project's build directory 148 /// (see [Project#buildDirectory]). 149 /// 150 /// @return the destination 151 /// 152 public Path destination() { 153 return destination.get(); 154 } 155 156 /// Sets the destination directory. The [Path] is resolved against 157 /// the project's build directory (see [Project#buildDirectory]). 158 /// 159 /// @param destination the new destination 160 /// @return the application builder 161 /// 162 public ApplicationBuilder destination(Path destination) { 163 this.destination 164 = () -> project().buildDirectory().resolve(destination); 165 return this; 166 } 167 168 /// Sets the destination directory. 169 /// 170 /// @param destination the new destination 171 /// @return the jar generator 172 /// 173 public ApplicationBuilder destination(Supplier<Path> destination) { 174 this.destination = destination; 175 return this; 176 } 177 178 /// Returns the base name of the generated TAR or ZIP file. The base 179 /// name is the file name without the extension. Defaults to the 180 /// project's name followed by its version. 181 /// 182 /// @return the string 183 /// 184 public String distributionBaseName() { 185 return distributionBaseName.get(); 186 } 187 188 /// Sets the supplier for obtaining the name of the generated 189 /// ZIP or TAR file's base name in [ResourceProviderSpi#provide]. 190 /// 191 /// @param distributionBaseName the distribution base name 192 /// @return the application builder 193 /// 194 public ApplicationBuilder 195 distributionBaseName(Supplier<String> distributionBaseName) { 196 this.distributionBaseName = distributionBaseName; 197 return this; 198 } 199 200 /// Returns the main class name. 201 /// 202 /// @return the main class name 203 /// 204 public String mainClassName() { 205 return config.mainClassName(); 206 } 207 208 /// Sets the name of the main class (the application entry point). 209 /// 210 /// @param name the new main class name 211 /// @return the jar generator for method chaining 212 /// 213 public ApplicationBuilder mainClassName(String name) { 214 config.mainClassName(Objects.requireNonNull(name)); 215 return this; 216 } 217 218 /// Passes the mutable list of JVM options to the given consumer for 219 /// modification. The start script distinguishes between these options, 220 /// which reflect settings required by the application, and the 221 /// `JAVA_OPTS` that may be used when starting the application to tune 222 /// the JVM for specific environments. 223 /// 224 /// @param modifier the modifier 225 /// @return the list 226 /// 227 public ApplicationBuilder 228 applicationJvmOpts(Consumer<List<String>> modifier) { 229 modifier.accept(config.applicationJvmOpts()); 230 return this; 231 } 232 233 /// Adds the given classpath resources to the application. 234 /// 235 /// @param resources the resources 236 /// @return the application builder 237 /// 238 public ApplicationBuilder 239 add(Stream<? extends ClasspathElement> resources) { 240 resourceStreams.add(resources); 241 return this; 242 } 243 244 @Override 245 public ResourceRetriever addFrom(Stream<ResourceProvider> providers) { 246 this.providers.add(providers); 247 return this; 248 } 249 250 @Override 251 protected <T extends Resource> Collection<T> 252 doProvide(ResourceRequest<T> request) { 253 if (!request.accepts(ApplicationZipFileType) 254 && !request.accepts(ApplicationTarFileType) 255 && !request.accepts(CleanlinessType)) { 256 return Collections.emptyList(); 257 } 258 259 // Maybe only delete 260 if (request.accepts(CleanlinessType)) { 261 destination() 262 .resolve(distributionBaseName() + ".zip").toFile().delete(); 263 destination() 264 .resolve(distributionBaseName() + ".tar").toFile().delete(); 265 return Collections.emptyList(); 266 } 267 268 // Make sure mainClass is set 269 if (mainClassName() == null) { 270 throw new ConfigurationException().from(this) 271 .message("Main class must be set for %s", name()); 272 } 273 274 // Prepare the application file 275 var destDir = destination(); 276 if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) { 277 throw new ConfigurationException().from(this) 278 .message("Cannot create directory " + destDir); 279 } 280 281 // Collect jars 282 if (!providersProcessed) { 283 resourceStreams.add(providers.stream() 284 .map(p -> p.resources(of(LibraryJarFileType).usingAll())) 285 .flatMap(s -> s)); 286 providersProcessed = true; 287 } 288 var cpes = Resources.with(ClasspathElementType); 289 var repoRefs = Resources.with(MvnRepoResourceType); 290 resourceStreams.stream().forEach(r -> { 291 if (r instanceof MvnRepoJarFile repoJar) { 292 repoRefs.add(repoJar.reference()); 293 } else { 294 cpes.add(r); 295 } 296 }); 297 // Jar files from maven repositories must be resolved before 298 // they can be added to the application to avoid duplicates. 299 var lookup = new MvnRepoLookup(); 300 lookup.resolve(repoRefs.stream()); 301 project().context().resources(lookup, of(ClasspathElementType) 302 .using(Consume, Reveal, Supply, Expose)) 303 .forEach(cpe -> { 304 if (cpe instanceof MvnRepoLibraryJarFile jarFile) { 305 cpes.add(jarFile); 306 } 307 }); 308 309 // Now build distribution 310 FileResource distFile; 311 if (request.accepts(ApplicationZipFileType)) { 312 distFile = buildZip(cpes); 313 } else { 314 distFile = buildTar(cpes); 315 } 316 @SuppressWarnings("unchecked") 317 var result = (T) distFile; 318 return List.of(result); 319 } 320 321 private FileResource buildZip(Resources<ClasspathElement> cpes) { 322 var zipFile = ZipFile.of(ApplicationZipFileType, 323 destination().resolve(distributionBaseName() + ".zip")); 324 if (cpes.isNewerThan(zipFile)) { 325 logger.atInfo().log("%s building %s", this, zipFile); 326 new ZipDistributionBuilder().build(zipFile, config, cpes); 327 } else { 328 logger.atFine().log("%s found %s to be up to date", this, zipFile); 329 } 330 return zipFile; 331 } 332 333 private FileResource buildTar(Resources<ClasspathElement> cpes) { 334 var tarFile = TarFile.of(ApplicationTarFileType, 335 destination().resolve(distributionBaseName() + ".tar")); 336 if (cpes.isNewerThan(tarFile)) { 337 logger.atInfo().log("%s building %s", this, tarFile); 338 new TarDistributionBuilder().build(tarFile, config, cpes); 339 } else { 340 logger.atFine().log("%s found %s to be up to date", this, tarFile); 341 } 342 return tarFile; 343 } 344}