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/// 097@SuppressWarnings("PMD.TooManyStaticImports") 098public class ApplicationBuilder extends AbstractGenerator 099 implements ResourceRetriever { 100 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 101 private Supplier<Path> destination 102 = () -> project().buildDirectory().resolve("distributions"); 103 private Supplier<String> distributionBaseName 104 = () -> project().name() + "-" + project().get(Version); 105 private final StreamCollector<ClasspathElement> resourceStreams 106 = StreamCollector.cached(); 107 private final StreamCollector<ResourceProvider> providers 108 = StreamCollector.uncached(); 109 private boolean providersProcessed; 110 private final ApplicationConfigurationData config 111 = new ApplicationConfigurationData(); 112 113 /// Initializes a new application builder. 114 /// 115 /// @param project the project 116 /// 117 public ApplicationBuilder(Project project) { 118 super(Objects.requireNonNull(project)); 119 config.executableName(project().name()); 120 } 121 122 @Override 123 public ApplicationBuilder name(String name) { 124 rename(name); 125 return this; 126 } 127 128 /// Returns the name of the script that starts the application. 129 /// The script for Windows has `.bat` appended to this name. 130 /// 131 /// @return the string 132 /// 133 public String executableName() { 134 return config.executableName(); 135 } 136 137 /// Sets the executable name. 138 /// 139 /// @param name the name 140 /// @return the application builder 141 /// 142 public ApplicationBuilder executableName(String name) { 143 config.executableName(name); 144 return this; 145 } 146 147 /// Returns the destination directory. Defaults to sub directory 148 /// `applications` in the project's build directory 149 /// (see [Project#buildDirectory]). 150 /// 151 /// @return the destination 152 /// 153 public Path destination() { 154 return destination.get(); 155 } 156 157 /// Sets the destination directory. The [Path] is resolved against 158 /// the project's build directory (see [Project#buildDirectory]). 159 /// 160 /// @param destination the new destination 161 /// @return the application builder 162 /// 163 public ApplicationBuilder destination(Path destination) { 164 this.destination 165 = () -> project().buildDirectory().resolve(destination); 166 return this; 167 } 168 169 /// Sets the destination directory. 170 /// 171 /// @param destination the new destination 172 /// @return the jar generator 173 /// 174 public ApplicationBuilder destination(Supplier<Path> destination) { 175 this.destination = destination; 176 return this; 177 } 178 179 /// Returns the base name of the generated TAR or ZIP file. The base 180 /// name is the file name without the extension. Defaults to the 181 /// project's name followed by its version. 182 /// 183 /// @return the string 184 /// 185 public String distributionBaseName() { 186 return distributionBaseName.get(); 187 } 188 189 /// Sets the supplier for obtaining the name of the generated 190 /// ZIP or TAR file's base name in [ResourceProviderSpi#provide]. 191 /// 192 /// @param distributionBaseName the distribution base name 193 /// @return the application builder 194 /// 195 public ApplicationBuilder 196 distributionBaseName(Supplier<String> distributionBaseName) { 197 this.distributionBaseName = distributionBaseName; 198 return this; 199 } 200 201 /// Returns the main class name. 202 /// 203 /// @return the main class name 204 /// 205 public String mainClassName() { 206 return config.mainClassName(); 207 } 208 209 /// Sets the name of the main class (the application entry point). 210 /// 211 /// @param name the new main class name 212 /// @return the jar generator for method chaining 213 /// 214 public ApplicationBuilder mainClassName(String name) { 215 config.mainClassName(Objects.requireNonNull(name)); 216 return this; 217 } 218 219 /// Passes the mutable list of JVM options to the given consumer for 220 /// modification. The start script distinguishes between these options, 221 /// which reflect settings required by the application, and the 222 /// `JAVA_OPTS` that may be used when starting the application to tune 223 /// the JVM for specific environments. 224 /// 225 /// @param modifier the modifier 226 /// @return the list 227 /// 228 public ApplicationBuilder 229 applicationJvmOpts(Consumer<List<String>> modifier) { 230 modifier.accept(config.applicationJvmOpts()); 231 return this; 232 } 233 234 /// Adds the given classpath resources to the application. 235 /// 236 /// @param resources the resources 237 /// @return the application builder 238 /// 239 public ApplicationBuilder 240 add(Stream<? extends ClasspathElement> resources) { 241 resourceStreams.add(resources); 242 return this; 243 } 244 245 @Override 246 public ResourceRetriever addFrom(Stream<ResourceProvider> providers) { 247 this.providers.add(providers); 248 return this; 249 } 250 251 @Override 252 protected <T extends Resource> Collection<T> 253 doProvide(ResourceRequest<T> request) { 254 if (!request.accepts(ApplicationZipFileType) 255 && !request.accepts(ApplicationTarFileType) 256 && !request.accepts(CleanlinessType)) { 257 return Collections.emptyList(); 258 } 259 260 // Maybe only delete 261 if (request.accepts(CleanlinessType)) { 262 destination() 263 .resolve(distributionBaseName() + ".zip").toFile().delete(); 264 destination() 265 .resolve(distributionBaseName() + ".tar").toFile().delete(); 266 return Collections.emptyList(); 267 } 268 269 // Make sure mainClass is set 270 if (mainClassName() == null) { 271 throw new ConfigurationException().from(this) 272 .message("Main class must be set for %s", name()); 273 } 274 275 // Prepare the application file 276 var destDir = destination(); 277 if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) { 278 throw new ConfigurationException().from(this) 279 .message("Cannot create directory " + destDir); 280 } 281 282 // Collect jars 283 if (!providersProcessed) { 284 resourceStreams.add(providers.stream() 285 .map(p -> p.resources(of(LibraryJarFileType).usingAll())) 286 .flatMap(s -> s)); 287 providersProcessed = true; 288 } 289 var cpes = Resources.with(ClasspathElementType); 290 var repoRefs = Resources.with(MvnRepoResourceType); 291 resourceStreams.stream().forEach(r -> { 292 if (r instanceof MvnRepoJarFile repoJar) { 293 repoRefs.add(repoJar.reference()); 294 } else { 295 cpes.add(r); 296 } 297 }); 298 // Jar files from maven repositories must be resolved before 299 // they can be added to the application to avoid duplicates. 300 var lookup = new MvnRepoLookup(); 301 lookup.resolve(repoRefs.stream()); 302 project().context().resources(lookup, of(ClasspathElementType) 303 .using(Consume, Reveal, Supply, Expose)) 304 .forEach(cpe -> { 305 if (cpe instanceof MvnRepoLibraryJarFile jarFile) { 306 cpes.add(jarFile); 307 } 308 }); 309 310 // Now build distribution 311 FileResource distFile; 312 if (request.accepts(ApplicationZipFileType)) { 313 distFile = buildZip(cpes); 314 } else { 315 distFile = buildTar(cpes); 316 } 317 @SuppressWarnings("unchecked") 318 var result = (T) distFile; 319 return List.of(result); 320 } 321 322 private FileResource buildZip(Resources<ClasspathElement> cpes) { 323 var zipFile = ZipFile.of(ApplicationZipFileType, 324 destination().resolve(distributionBaseName() + ".zip")); 325 if (cpes.isNewerThan(zipFile)) { 326 logger.atInfo().log("%s building %s", this, zipFile); 327 new ZipDistributionBuilder().build(zipFile, config, cpes); 328 } else { 329 logger.atFine().log("%s found %s to be up to date", this, zipFile); 330 } 331 return zipFile; 332 } 333 334 private FileResource buildTar(Resources<ClasspathElement> cpes) { 335 var tarFile = TarFile.of(ApplicationTarFileType, 336 destination().resolve(distributionBaseName() + ".tar")); 337 if (cpes.isNewerThan(tarFile)) { 338 logger.atInfo().log("%s building %s", this, tarFile); 339 new TarDistributionBuilder().build(tarFile, config, cpes); 340 } else { 341 logger.atFine().log("%s found %s to be up to date", this, tarFile); 342 } 343 return tarFile; 344 } 345}