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