001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 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.mvnrepo; 020 021import java.io.BufferedInputStream; 022import java.io.BufferedOutputStream; 023import java.io.FileNotFoundException; 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.OutputStream; 027import java.io.PipedInputStream; 028import java.io.PipedOutputStream; 029import java.io.UncheckedIOException; 030import java.net.URI; 031import java.net.http.HttpClient; 032import java.net.http.HttpRequest; 033import java.net.http.HttpResponse; 034import java.nio.charset.StandardCharsets; 035import java.nio.file.Files; 036import java.nio.file.Path; 037import java.security.MessageDigest; 038import java.security.NoSuchAlgorithmException; 039import java.security.Security; 040import java.util.ArrayList; 041import java.util.List; 042import java.util.Optional; 043import java.util.concurrent.ExecutorService; 044import java.util.concurrent.Executors; 045import java.util.concurrent.atomic.AtomicBoolean; 046import java.util.function.Supplier; 047import java.util.stream.Stream; 048import java.util.zip.ZipEntry; 049import java.util.zip.ZipOutputStream; 050import org.apache.maven.model.building.DefaultModelBuilderFactory; 051import org.apache.maven.model.building.DefaultModelBuildingRequest; 052import org.apache.maven.model.building.ModelBuildingException; 053import org.bouncycastle.bcpg.ArmoredOutputStream; 054import org.bouncycastle.jce.provider.BouncyCastleProvider; 055import org.bouncycastle.openpgp.PGPException; 056import org.bouncycastle.openpgp.PGPPrivateKey; 057import org.bouncycastle.openpgp.PGPPublicKey; 058import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; 059import org.bouncycastle.openpgp.PGPSignature; 060import org.bouncycastle.openpgp.PGPSignatureGenerator; 061import org.bouncycastle.openpgp.PGPUtil; 062import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; 063import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; 064import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; 065import org.bouncycastle.util.encoders.Base64; 066import org.eclipse.aether.AbstractRepositoryListener; 067import org.eclipse.aether.DefaultRepositorySystemSession; 068import org.eclipse.aether.RepositoryEvent; 069import org.eclipse.aether.artifact.Artifact; 070import org.eclipse.aether.artifact.DefaultArtifact; 071import org.eclipse.aether.deployment.DeployRequest; 072import org.eclipse.aether.deployment.DeploymentException; 073import org.eclipse.aether.repository.RemoteRepository; 074import org.eclipse.aether.util.artifact.SubArtifact; 075import org.eclipse.aether.util.repository.AuthenticationBuilder; 076import org.jdrupes.builder.api.BuildException; 077import static org.jdrupes.builder.api.Intend.*; 078import org.jdrupes.builder.api.Project; 079import org.jdrupes.builder.api.Resource; 080import org.jdrupes.builder.api.ResourceRequest; 081import static org.jdrupes.builder.api.ResourceRequest.requestFor; 082import org.jdrupes.builder.core.AbstractGenerator; 083import org.jdrupes.builder.java.JavadocJarFile; 084import org.jdrupes.builder.java.LibraryJarFile; 085import org.jdrupes.builder.java.SourcesJarFile; 086import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId; 087import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 088 089/// The Class MvnPublicationGenerator. 090/// 091@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports", 092 "PMD.GodClass", "PMD.TooManyMethods" }) 093public class MvnPublicationGenerator extends AbstractGenerator { 094 095 private URI uploadUri; 096 private URI snapshotUri; 097 private String repoUser; 098 private String repoPass; 099 private JcaPGPContentSignerBuilder signerBuilder; 100 private PGPPrivateKey privateKey; 101 private PGPPublicKey publicKey; 102 private Supplier<Path> artifactDirectory 103 = () -> project().buildDirectory().resolve("publications/maven"); 104 private boolean keepSubArtifacts; 105 106 /// Creates a new Maven publication generator. 107 /// 108 /// @param project the project 109 /// 110 public MvnPublicationGenerator(Project project) { 111 super(project); 112 uploadUri = URI 113 .create("https://central.sonatype.com/api/v1/publisher/upload"); 114 } 115 116 /// Sets the upload URI. 117 /// 118 /// @param uri the repository URI 119 /// @return the maven publication generator 120 /// 121 public MvnPublicationGenerator uploadUri(URI uri) { 122 this.uploadUri = uri; 123 return this; 124 } 125 126 /// Returns the upload URI. Defaults to 127 /// `https://central.sonatype.com/api/v1/publisher/upload`. 128 /// 129 /// @return the uri 130 /// 131 public URI uploadUri() { 132 return uploadUri; 133 } 134 135 /// Sets the Maven snapshot repository URI. 136 /// 137 /// @param uri the snapshot repository URI 138 /// @return the maven publication generator 139 /// 140 public MvnPublicationGenerator snapshotRepository(URI uri) { 141 this.snapshotUri = uri; 142 return this; 143 } 144 145 /// Returns the snapshot repository. Defaults to 146 /// `https://central.sonatype.com/repository/maven-snapshots/`. 147 /// 148 /// @return the uri 149 /// 150 public URI snapshotRepository() { 151 return snapshotUri; 152 } 153 154 /// Sets the Maven repository credentials. 155 /// 156 /// @param user the username 157 /// @param pass the password 158 /// @return the maven publication generator 159 /// 160 public MvnPublicationGenerator credentials(String user, String pass) { 161 this.repoUser = user; 162 this.repoPass = pass; 163 return this; 164 } 165 166 /// Keep generated sub artifacts (checksums, signatures) 167 /// 168 /// @return the mvn publication generator 169 /// 170 public MvnPublicationGenerator keepSubArtifacts() { 171 keepSubArtifacts = true; 172 return this; 173 } 174 175 /// Returns the directory where additional artifacts are created. 176 /// Defaults to sub directory `publications/maven` in the project's 177 /// build directory (see [Project#buildDirectory]). 178 /// 179 /// @return the directory 180 /// 181 public Path artifactDirectory() { 182 return artifactDirectory.get(); 183 } 184 185 /// Sets the directory where additional artifacts are created. 186 /// The [Path] is resolved against the project's build directory 187 /// (see [Project#buildDirectory]). If `destination` is `null`, 188 /// the additional artifacts are created in the directory where 189 /// the base artifact is found. 190 /// 191 /// @param directory the new directory 192 /// @return the maven publication generator 193 /// 194 public MvnPublicationGenerator artifactDirectory(Path directory) { 195 if (directory == null) { 196 this.artifactDirectory = () -> null; 197 return this; 198 } 199 this.artifactDirectory 200 = () -> project().buildDirectory().resolve(directory); 201 return this; 202 } 203 204 /// Sets the directory where additional artifacts are created. 205 /// If the [Supplier] returns `null`, the additional artifacts 206 /// are created in the directory where the base artifact is found. 207 /// 208 /// @param directory the new directory 209 /// @return the maven publication generator 210 /// 211 public MvnPublicationGenerator artifactDirectory(Supplier<Path> directory) { 212 this.artifactDirectory = directory; 213 return this; 214 } 215 216 @Override 217 protected <T extends Resource> Stream<T> 218 doProvide(ResourceRequest<T> requested) { 219 if (!requested.includes(MvnPublicationType)) { 220 return Stream.empty(); 221 } 222 PomFile pomResource = resourceCheck(project().supplied( 223 requestFor(PomFile.class)), "POM file"); 224 if (pomResource == null) { 225 return Stream.empty(); 226 } 227 var jarResource = resourceCheck(project().getFrom(project() 228 .providers(Expose, Forward), requestFor(LibraryJarFile.class)), 229 "jar file"); 230 if (jarResource == null) { 231 return Stream.empty(); 232 } 233 var srcsIter = project().supplied(requestFor(SourcesJarFile.class)) 234 .iterator(); 235 SourcesJarFile srcsFile = null; 236 if (srcsIter.hasNext()) { 237 srcsFile = srcsIter.next(); 238 if (srcsIter.hasNext()) { 239 log.severe(() -> "More than one sources jar resources found."); 240 return Stream.empty(); 241 } 242 } 243 var jdIter = project().supplied(requestFor(JavadocJarFile.class)) 244 .iterator(); 245 JavadocJarFile jdFile = null; 246 if (jdIter.hasNext()) { 247 jdFile = jdIter.next(); 248 if (jdIter.hasNext()) { 249 log.severe(() -> "More than one javadoc jar resources found."); 250 return Stream.empty(); 251 } 252 } 253 254 // Deploy what we've found 255 @SuppressWarnings("unchecked") 256 var result = (Stream<T>) deploy(pomResource, jarResource, srcsFile, 257 jdFile); 258 return result; 259 } 260 261 private <T extends Resource> T resourceCheck(Stream<T> resources, 262 String name) { 263 var iter = resources.iterator(); 264 if (!iter.hasNext()) { 265 log.severe(() -> "No " + name + " resource available."); 266 return null; 267 } 268 var result = iter.next(); 269 if (iter.hasNext()) { 270 log.severe(() -> "More than one " + name + " resource found."); 271 return null; 272 } 273 return result; 274 } 275 276 private record Deployable(Artifact artifact, boolean temporary) { 277 } 278 279 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 280 private Stream<?> deploy(PomFile pomResource, LibraryJarFile jarResource, 281 SourcesJarFile srcsJar, JavadocJarFile javadocJar) { 282 Artifact mainArtifact; 283 try { 284 mainArtifact = mainArtifact(pomResource); 285 } catch (ModelBuildingException e) { 286 throw new BuildException( 287 "Cannot build model from POM: " + e.getMessage(), e); 288 } 289 if (artifactDirectory() != null) { 290 artifactDirectory().toFile().mkdirs(); 291 } 292 List<Deployable> toDeploy = new ArrayList<>(); 293 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom", 294 pomResource.path().toFile())); 295 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar", 296 jarResource.path().toFile())); 297 if (srcsJar != null) { 298 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources", 299 "jar", srcsJar.path().toFile())); 300 } 301 if (javadocJar != null) { 302 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc", 303 "jar", javadocJar.path().toFile())); 304 } 305 306 try { 307 if (mainArtifact.isSnapshot()) { 308 deploySnapshot(toDeploy); 309 } else { 310 deployRelease(mainArtifact, toDeploy); 311 } 312 } catch (DeploymentException e) { 313 throw new BuildException( 314 "Deployment failed for " + mainArtifact, e); 315 } finally { 316 if (!keepSubArtifacts) { 317 toDeploy.stream().filter(Deployable::temporary).forEach(d -> { 318 d.artifact().getFile().delete(); 319 }); 320 } 321 } 322 return Stream.of(project().newResource(MvnPublicationType, 323 mainArtifact.getGroupId() + ":" + mainArtifact.getArtifactId() 324 + ":" + mainArtifact.getVersion())); 325 } 326 327 private Artifact mainArtifact(PomFile pomResource) 328 throws ModelBuildingException { 329 var pomFile = pomResource.path().toFile(); 330 var req = new DefaultModelBuildingRequest().setPomFile(pomFile); 331 var model = new DefaultModelBuilderFactory().newInstance() 332 .build(req).getEffectiveModel(); 333 return new DefaultArtifact(model.getGroupId(), model.getArtifactId(), 334 "jar", model.getVersion()); 335 } 336 337 private void addWithGenerated(List<Deployable> toDeploy, 338 Artifact artifact) { 339 // Add main artifact 340 toDeploy.add(new Deployable(artifact, false)); 341 342 // Generate .md5 and .sha1 checksum files 343 var artifactFile = artifact.getFile().toPath(); 344 try { 345 MessageDigest md5 = MessageDigest.getInstance("MD5"); 346 MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); 347 try (var fis = Files.newInputStream(artifactFile)) { 348 byte[] buffer = new byte[8192]; 349 while (true) { 350 int read = fis.read(buffer); 351 if (read < 0) { 352 break; 353 } 354 md5.update(buffer, 0, read); 355 sha1.update(buffer, 0, read); 356 } 357 } 358 var fileName = artifactFile.getFileName().toString(); 359 360 // Handle generated md5 361 var md5Path = destinationPath(artifactFile, fileName + ".md5"); 362 Files.writeString(md5Path, toHex(md5.digest())); 363 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5", 364 md5Path.toFile()), true)); 365 366 // Handle generated sha1 367 var sha1Path = destinationPath(artifactFile, fileName + ".sha1"); 368 Files.writeString(sha1Path, toHex(sha1.digest())); 369 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1", 370 sha1Path.toFile()), true)); 371 372 // Add signature as yet another artifact 373 var sigPath = signResource(artifactFile); 374 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc", 375 sigPath.toFile()), true)); 376 } catch (NoSuchAlgorithmException | IOException | PGPException e) { 377 throw new BuildException(e); 378 } 379 } 380 381 private Path destinationPath(Path base, String fileName) { 382 var dir = artifactDirectory(); 383 if (dir == null) { 384 base.resolveSibling(fileName); 385 } 386 return dir.resolve(fileName); 387 } 388 389 private static String toHex(byte[] bytes) { 390 char[] hexDigits = "0123456789abcdef".toCharArray(); 391 char[] result = new char[bytes.length * 2]; 392 393 for (int i = 0; i < bytes.length; i++) { 394 int unsigned = bytes[i] & 0xFF; 395 result[i * 2] = hexDigits[unsigned >>> 4]; 396 result[i * 2 + 1] = hexDigits[unsigned & 0x0F]; 397 } 398 return new String(result); 399 } 400 401 private void initSigning() 402 throws FileNotFoundException, IOException, PGPException { 403 if (signerBuilder != null) { 404 return; 405 } 406 var keyRingFileName 407 = project().context().property("signing.secretKeyRingFile"); 408 var keyId = project().context().property("signing.keyId"); 409 var passphrase 410 = project().context().property("signing.password").toCharArray(); 411 if (keyRingFileName == null || keyId == null || passphrase == null) { 412 log.warning(() -> "Cannot sign artifacts: properties not set."); 413 return; 414 } 415 Security.addProvider(new BouncyCastleProvider()); 416 var secretKeyRingCollection = new PGPSecretKeyRingCollection( 417 PGPUtil.getDecoderStream( 418 Files.newInputStream(Path.of(keyRingFileName))), 419 new JcaKeyFingerprintCalculator()); 420 var secretKey = secretKeyRingCollection 421 .getSecretKey(Long.parseUnsignedLong(keyId, 16)); 422 publicKey = secretKey.getPublicKey(); 423 privateKey = secretKey.extractPrivateKey( 424 new JcePBESecretKeyDecryptorBuilder().setProvider("BC") 425 .build(passphrase)); 426 signerBuilder = new JcaPGPContentSignerBuilder( 427 publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC"); 428 429 } 430 431 private Path signResource(Path resource) 432 throws PGPException, IOException { 433 initSigning(); 434 PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( 435 signerBuilder, publicKey); 436 signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); 437 var sigPath = destinationPath(resource, 438 resource.getFileName() + ".asc"); 439 try (InputStream fileInput = new BufferedInputStream( 440 Files.newInputStream(resource)); 441 OutputStream sigOut 442 = new ArmoredOutputStream(Files.newOutputStream(sigPath))) { 443 byte[] buffer = new byte[8192]; 444 while (true) { 445 int read = fileInput.read(buffer); 446 if (read < 0) { 447 break; 448 } 449 signatureGenerator.update(buffer, 0, read); 450 } 451 PGPSignature signature = signatureGenerator.generate(); 452 signature.encode(sigOut); 453 } 454 return sigPath; 455 } 456 457 private void deploySnapshot(List<Deployable> toDeploy) 458 throws DeploymentException { 459 // Now deploy everything 460 @SuppressWarnings("PMD.CloseResource") 461 var context = MvnRepoLookup.rootContext(); 462 var session = new DefaultRepositorySystemSession( 463 context.repositorySystemSession()); 464 var startMsgLogged = new AtomicBoolean(false); 465 session.setRepositoryListener(new AbstractRepositoryListener() { 466 @Override 467 public void artifactDeploying(RepositoryEvent event) { 468 if (!startMsgLogged.getAndSet(true)) { 469 log.info(() -> "Start deploying artifacts..."); 470 } 471 } 472 473 @Override 474 public void artifactDeployed(RepositoryEvent event) { 475 if (!"jar".equals(event.getArtifact().getExtension())) { 476 return; 477 } 478 log.info(() -> "Deployed: " + event.getArtifact()); 479 } 480 481 @Override 482 public void metadataDeployed(RepositoryEvent event) { 483 log.info(() -> "Deployed: " + event.getMetadata()); 484 } 485 486 }); 487 var repo = new RemoteRepository.Builder("mine", "default", 488 snapshotUri.toString()) 489 .setAuthentication(new AuthenticationBuilder() 490 .addUsername(repoUser).addPassword(repoPass).build()) 491 .build(); 492 var deployReq = new DeployRequest().setRepository(repo); 493 toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact); 494 context.repositorySystem().deploy(session, deployReq); 495 } 496 497 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 498 private void deployRelease(Artifact mainArtifact, 499 List<Deployable> toDeploy) { 500 // Create zip file with all artifacts for release, see 501 // https://central.sonatype.org/publish/publish-portal-upload/ 502 var zipName = Optional.ofNullable(project().get(ArtifactId)) 503 .orElse(project().name()) + "-" + mainArtifact.getVersion() 504 + "-release.zip"; 505 var zipPath = artifactDirectory().resolve(zipName); 506 try { 507 Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/')) 508 .resolve(mainArtifact.getArtifactId()) 509 .resolve(mainArtifact.getVersion()); 510 try (ZipOutputStream zos 511 = new ZipOutputStream(Files.newOutputStream(zipPath))) { 512 for (Deployable d : toDeploy) { 513 var artifact = d.artifact(); 514 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 515 var entry = new ZipEntry(praefix.resolve( 516 artifact.getArtifactId() + "-" + artifact.getVersion() 517 + (artifact.getClassifier().isEmpty() 518 ? "" 519 : "-" + artifact.getClassifier()) 520 + "." + artifact.getExtension()) 521 .toString()); 522 zos.putNextEntry(entry); 523 try (var fis = Files.newInputStream( 524 artifact.getFile().toPath())) { 525 fis.transferTo(zos); 526 } 527 zos.closeEntry(); 528 } 529 } 530 } catch (IOException e) { 531 throw new BuildException( 532 "Failed to create release zip: " + e.getMessage(), e); 533 } 534 535 try (var client = HttpClient.newHttpClient()) { 536 var boundary = "===" + System.currentTimeMillis() + "==="; 537 var token = new String(Base64.encode((repoUser + ":" + repoPass) 538 .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 539 HttpRequest request = HttpRequest.newBuilder().uri(URI.create( 540 "https://central.sonatype.com/api/v1/publisher/upload")) 541 .header("Authorization", "Bearer " + token) 542 .header("Content-Type", 543 "multipart/form-data; boundary=" + boundary) 544 .POST(HttpRequest.BodyPublishers 545 .ofInputStream(() -> getAsMultipart(zipPath, boundary))) 546 .build(); 547 HttpResponse<String> response = client.send(request, 548 HttpResponse.BodyHandlers.ofString()); 549 if (response.statusCode() / 100 != 2) { 550 throw new BuildException( 551 "Failed to upload release bundle: " + response.body()); 552 } 553 } catch (IOException | InterruptedException e) { 554 throw new BuildException( 555 "Failed to upload release bundle: " + e.getMessage(), e); 556 } 557 } 558 559 @SuppressWarnings("PMD.UseTryWithResources") 560 private InputStream getAsMultipart(Path zipPath, String boundary) { 561 // Use Piped streams for streaming multipart content 562 var fromPipe = new PipedInputStream(); 563 final String lineFeed = "\r\n"; 564 565 // Write multipart content to pipe 566 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); 567 executor.submit(() -> { 568 try (var mpOut = new BufferedOutputStream( 569 new PipedOutputStream(fromPipe))) { 570 @SuppressWarnings("PMD.InefficientStringBuffering") 571 StringBuilder intro = new StringBuilder(100) 572 .append("--").append(boundary).append(lineFeed) 573 .append("Content-Disposition: form-data; name=\"bundle\";" 574 + " filename=\"%s\"".formatted(zipPath.getFileName())) 575 .append(lineFeed) 576 .append("Content-Type: application/octet-stream") 577 .append(lineFeed).append(lineFeed); 578 mpOut.write( 579 intro.toString().getBytes(StandardCharsets.US_ASCII)); 580 Files.newInputStream(zipPath).transferTo(mpOut); 581 mpOut.write((lineFeed + "--" + boundary + "--") 582 .getBytes(StandardCharsets.US_ASCII)); 583 } catch (IOException e) { 584 throw new UncheckedIOException(e); 585 } finally { 586 executor.close(); 587 } 588 }); 589 return fromPipe; 590 } 591 592}