HealSnapshotMetadataMojo.java
/*
* Copyright © 2026 IKE Network (support@ike.network)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package network.ike.plugin;
import network.ike.plugin.deploy.SnapshotMetadataHealer;
import network.ike.plugin.deploy.SnapshotMetadataHealer.Credentials;
import network.ike.plugin.deploy.SnapshotMetadataHealer.Result;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.model.DeploymentRepository;
import org.apache.maven.api.model.DistributionManagement;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.apache.maven.api.settings.Server;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Optional;
/**
* {@code ike:heal-snapshot-metadata} — before a snapshot deploy, repairs the
* version's {@code maven-metadata.xml} on the deploy target when its checksum
* sidecars no longer match it (IKE-Network/ike-issues#1160).
*
* <p>Nexus can leave that pair inconsistent after its background metadata
* rebuild (IKE-Network/ike-issues#1107), and every later deploy of the
* version then fails checksum validation. {@code ike-parent} binds this goal
* to {@code before:deploy}, so the repair happens ahead of
* {@code maven-deploy-plugin} and a plain {@code install} never reaches the
* network. See {@link SnapshotMetadataHealer} for exactly what is read and
* deleted.
*
* <p>The goal never fails the build. A repository it cannot read or repair
* is logged, and the deploy that follows reports the underlying problem.
*/
@org.apache.maven.api.plugin.annotations.Mojo(name = IkeGoal.NAME_HEAL_SNAPSHOT_METADATA)
public class HealSnapshotMetadataMojo implements Mojo {
/** Maven logger, injected by the plugin runtime. */
@Inject
Log log;
/** The Maven session, for settings (server credentials) and properties. */
@Inject
Session session;
/** The project about to be deployed. */
@Inject
Project project;
/** Skip the check entirely: plain Maven deploy behaviour. */
@Parameter(property = "ike.metadata.heal.skip", defaultValue = "false")
boolean skip;
/** Timeout, in seconds, for each request to the repository. */
@Parameter(property = "ike.metadata.heal.timeoutSeconds", defaultValue = "20")
int timeoutSeconds;
/** Creates this goal instance. */
public HealSnapshotMetadataMojo() {}
@Override
public void execute() {
if (skip) {
log.info("Snapshot metadata check skipped (ike.metadata.heal.skip)");
return;
}
String version = project.getVersion();
if (!version.endsWith("-SNAPSHOT")) {
log.debug("Snapshot metadata check: " + version + " is a release; nothing to do");
return;
}
if (Boolean.parseBoolean(property("maven.deploy.skip"))) {
log.debug("Snapshot metadata check: maven.deploy.skip is set; nothing to do");
return;
}
Optional<Target> target = snapshotTarget();
if (target.isEmpty()) {
log.debug("Snapshot metadata check: no snapshot deploy repository; nothing to do");
return;
}
Target repository = target.get();
SnapshotMetadataHealer healer = new SnapshotMetadataHealer(
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeoutSeconds))
.followRedirects(HttpClient.Redirect.NORMAL)
.build(),
Duration.ofSeconds(timeoutSeconds));
Result result = healer.heal(URI.create(repository.url()), credentials(repository.id()),
project.getGroupId(), project.getArtifactId(), version);
report(repository, result);
}
private void report(Target repository, Result result) {
String where = project.getArtifactId() + ":" + project.getVersion()
+ " on " + repository.id();
switch (result.outcome()) {
case HEALED -> {
log.warn("Snapshot metadata for " + where + " did not match its checksums ("
+ result.detail() + "); deleted so this deploy rewrites it"
+ " (IKE-Network/ike-issues#1107):");
result.deleted().forEach(path -> log.warn(" deleted " + path));
}
case DELETE_FAILED -> {
log.warn("Snapshot metadata for " + where + " does not match its checksums,"
+ " and could not be deleted: " + result.detail());
log.warn(" The deploy will likely fail with 'Checksum validation failed'."
+ " Delete the version's maven-metadata.xml and its checksum files"
+ " on the repository by hand (IKE-Network/ike-issues#1107).");
result.deleted().forEach(path -> log.warn(" deleted " + path));
}
case UNREACHABLE -> log.warn("Snapshot metadata check for " + where
+ " could not read the repository: " + result.detail());
case CONSISTENT, ABSENT, NOT_A_SNAPSHOT -> log.debug("Snapshot metadata for "
+ where + ": " + result.detail());
}
}
/** The snapshot deploy target: an {@code alt*DeploymentRepository} override, else the POM's. */
private Optional<Target> snapshotTarget() {
for (String override : new String[]{"altSnapshotDeploymentRepository", "altDeploymentRepository"}) {
String value = property(override);
if (value != null && !value.isBlank()) {
return Target.parse(value);
}
}
DistributionManagement distribution = project.getModel().getDistributionManagement();
if (distribution == null) {
return Optional.empty();
}
DeploymentRepository repository = distribution.getSnapshotRepository() != null
? distribution.getSnapshotRepository()
: distribution.getRepository();
if (repository == null || repository.getUrl() == null || repository.getId() == null) {
return Optional.empty();
}
return Optional.of(new Target(repository.getId(), repository.getUrl()));
}
/** The server credentials Maven's settings hold for {@code id}, when usable. */
private Optional<Credentials> credentials(String id) {
for (Server server : session.getSettings().getServers()) {
if (!id.equals(server.getId())) {
continue;
}
String username = server.getUsername();
String password = server.getPassword();
if (username == null || password == null) {
return Optional.empty();
}
if (password.startsWith("{") && password.endsWith("}")) {
log.debug("Snapshot metadata check: the password for " + id
+ " is encrypted; reading without credentials");
return Optional.empty();
}
return Optional.of(new Credentials(username, password));
}
return Optional.empty();
}
/** A user, system or project property; the first defined wins. */
private String property(String name) {
String value = session.getUserProperties().get(name);
if (value == null) {
value = session.getSystemProperties().get(name);
}
if (value == null) {
value = project.getModel().getProperties().get(name);
}
return value;
}
/**
* A deploy target: a server id and a repository URL.
*
* @param id the server id, which keys the credentials in settings
* @param url the repository URL
*/
record Target(String id, String url) {
/**
* Parses Maven's {@code id::url} (or legacy {@code id::layout::url})
* deployment-repository override.
*/
static Optional<Target> parse(String value) {
String[] parts = value.split("::");
if (parts.length == 2) {
return Optional.of(new Target(parts[0], parts[1]));
}
if (parts.length == 3) {
return Optional.of(new Target(parts[0], parts[2]));
}
return Optional.empty();
}
}
}