CentralDeploySentinel.java

package network.ike.plugin;

import org.apache.maven.api.plugin.MojoException;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Properties;
import java.util.stream.Stream;

/**
 * Status record for an asynchronous Maven Central deploy
 * (IKE-Network/ike-issues#484). Persisted as a Java {@code
 * .properties} file under a discoverable cache directory so the
 * outcome survives the originating Maven JVM exiting.
 *
 * <p>State machine (single forward transition): {@link State#PENDING}
 * is written by the release Mojo when it spawns the detached
 * subprocess; the subprocess itself rewrites the file to
 * {@link State#SUCCESS} or {@link State#FAILURE} when it finishes.
 *
 * <p>Default location: {@code ~/.cache/ike-release/}. Chosen over
 * {@code target/} so the sentinel survives a {@code mvn clean} and
 * is discoverable across repos by {@link CentralStatusMojo}, which
 * walks the directory to report in-flight deploys workspace-wide.
 */
public final class CentralDeploySentinel {

    /** Sentinel lifecycle states. */
    public enum State {
        /** Subprocess spawned, still running. */
        PENDING,
        /** Central deploy succeeded — bundle published. */
        SUCCESS,
        /** All retry attempts exhausted; see {@link #lastError()}. */
        FAILURE
    }

    /** Default sentinel directory: {@code ~/.cache/ike-release/}. */
    public static final Path DEFAULT_DIR = Paths.get(
            System.getProperty("user.home"), ".cache", "ike-release");

    // ── Property keys ───────────────────────────────────────────
    static final String KEY_STATE = "state";
    static final String KEY_ARTIFACT_ID = "artifactId";
    static final String KEY_VERSION = "version";
    static final String KEY_STARTED = "started";
    static final String KEY_FINISHED = "finished";
    static final String KEY_ATTEMPTS = "attempts";
    static final String KEY_MAX_ATTEMPTS = "maxAttempts";
    static final String KEY_LAST_ERROR = "lastError";
    static final String KEY_LOG_FILE = "logFile";
    static final String KEY_PID = "pid";
    static final String KEY_NOTE = "note";

    private final State state;
    private final String artifactId;
    private final String version;
    private final Instant started;
    private final Instant finished;
    private final int attempts;
    private final int maxAttempts;
    private final String lastError;
    private final Path logFile;
    private final long pid;
    private final String note;
    private final Path path;

    private CentralDeploySentinel(Builder b) {
        this.state = b.state;
        this.artifactId = b.artifactId;
        this.version = b.version;
        this.started = b.started;
        this.finished = b.finished;
        this.attempts = b.attempts;
        this.maxAttempts = b.maxAttempts;
        this.lastError = b.lastError;
        this.logFile = b.logFile;
        this.pid = b.pid;
        this.note = b.note;
        this.path = b.path;
    }

    /**
     * Lifecycle state of this sentinel.
     *
     * @return the state
     */
    public State state() { return state; }

    /**
     * Project artifactId the deploy belongs to.
     *
     * @return the artifactId
     */
    public String artifactId() { return artifactId; }

    /**
     * Release version being deployed.
     *
     * @return the version
     */
    public String version() { return version; }

    /**
     * When the deploy was spawned (UTC).
     *
     * @return the start instant
     */
    public Instant started() { return started; }

    /**
     * When the deploy reached a terminal state, or {@code null}
     * while still {@link State#PENDING}.
     *
     * @return the finish instant, or {@code null} when pending
     */
    public Instant finished() { return finished; }

    /**
     * Number of attempts taken — refreshed by the subprocess
     * before each upload so {@code ike:central-status} reflects
     * live progress.
     *
     * @return attempts taken so far
     */
    public int attempts() { return attempts; }

    /**
     * Configured maximum attempts for the retry loop, recorded
     * so the displayed {@code attempts/max} ratio is meaningful
     * even after the configuration changes between releases.
     *
     * @return the configured maximum
     */
    public int maxAttempts() { return maxAttempts; }

    /**
     * Short failure summary captured by the subprocess when the
     * retry budget is exhausted.
     *
     * @return failure summary, or {@code null} when not failed
     */
    public String lastError() { return lastError; }

    /**
     * Path the subprocess streams its deploy log to.
     *
     * @return the log-file path, or {@code null} if not recorded
     */
    public Path logFile() { return logFile; }

    /**
     * PID of the spawned subprocess. Written while {@link State#PENDING}
     * so {@code ike:central-status} can detect orphaned sentinels
     * (subprocess died, sentinel never advanced).
     *
     * @return the PID, or 0 if not recorded
     */
    public long pid() { return pid; }

    /**
     * Advisory note recorded alongside a non-failure outcome —
     * e.g. a SUCCESS where JReleaser's publish poll timed out, so
     * the upload is confirmed but PUBLISHED was never observed.
     * Distinct from {@link #lastError()}, which is set only on
     * FAILURE.
     *
     * @return the note, or {@code null} if none was recorded
     */
    public String note() { return note; }

    /**
     * Absolute path to this sentinel file on disk.
     *
     * @return the sentinel file path
     */
    public Path path() { return path; }

    /**
     * Resolve the canonical sentinel-file path for a project.
     *
     * @param dir        sentinel directory (typically {@link #DEFAULT_DIR})
     * @param artifactId project artifactId
     * @param version    release version
     * @return {@code <dir>/<artifactId>-<version>.properties}
     */
    public static Path resolvePath(Path dir, String artifactId,
                                    String version) {
        return dir.resolve(artifactId + "-" + version + ".properties");
    }

    /**
     * Read a sentinel file into a value object.
     *
     * @param path the sentinel file
     * @return the parsed sentinel
     * @throws MojoException if the file is missing or malformed
     */
    public static CentralDeploySentinel read(Path path) {
        if (!Files.isRegularFile(path)) {
            throw new MojoException("Sentinel not found: " + path);
        }
        Properties p = new Properties();
        try (InputStream in = Files.newInputStream(path)) {
            p.load(in);
        } catch (IOException e) {
            throw new MojoException("Could not read sentinel "
                    + path + ": " + e.getMessage(), e);
        }
        return new Builder()
                .path(path)
                .state(parseState(p, path))
                .artifactId(required(p, KEY_ARTIFACT_ID, path))
                .version(required(p, KEY_VERSION, path))
                .started(parseInstant(p, KEY_STARTED, true, path))
                .finished(parseInstant(p, KEY_FINISHED, false, path))
                .attempts(parseInt(p, KEY_ATTEMPTS, 0))
                .maxAttempts(parseInt(p, KEY_MAX_ATTEMPTS, 0))
                .lastError(p.getProperty(KEY_LAST_ERROR))
                .logFile(parseOptionalPath(p, KEY_LOG_FILE))
                .pid(parseLong(p, KEY_PID, 0L))
                .note(p.getProperty(KEY_NOTE))
                .build();
    }

    /**
     * List all sentinel files under a directory, newest first. Files
     * that fail to parse are skipped — listing must be robust against
     * partially-written files an in-flight subprocess is updating.
     *
     * @param dir the sentinel directory (may not exist; treated as empty)
     * @return parsed sentinels, ordered by {@code started} descending
     */
    public static List<CentralDeploySentinel> listAll(Path dir) {
        if (!Files.isDirectory(dir)) {
            return List.of();
        }
        List<CentralDeploySentinel> out = new ArrayList<>();
        try (Stream<Path> stream = Files.list(dir)) {
            stream.filter(p -> p.getFileName().toString()
                            .endsWith(".properties"))
                    .forEach(p -> {
                        try {
                            out.add(read(p));
                        } catch (RuntimeException ignored) {
                            // Skip partially-written / malformed entries.
                        }
                    });
        } catch (IOException e) {
            throw new MojoException("Could not list sentinel dir "
                    + dir + ": " + e.getMessage(), e);
        }
        out.sort(Comparator.comparing(CentralDeploySentinel::started)
                .reversed());
        return out;
    }

    /**
     * Write this sentinel to {@link #path()}. Atomic replace via
     * a temp-file + rename so concurrent readers never see a
     * half-written file.
     *
     * @throws MojoException on I/O failure
     */
    public void write() {
        Properties p = new Properties();
        p.setProperty(KEY_STATE, state.name());
        p.setProperty(KEY_ARTIFACT_ID, artifactId);
        p.setProperty(KEY_VERSION, version);
        p.setProperty(KEY_STARTED, started.toString());
        if (finished != null) {
            p.setProperty(KEY_FINISHED, finished.toString());
        }
        p.setProperty(KEY_ATTEMPTS, Integer.toString(attempts));
        p.setProperty(KEY_MAX_ATTEMPTS, Integer.toString(maxAttempts));
        if (lastError != null) {
            p.setProperty(KEY_LAST_ERROR, lastError);
        }
        if (logFile != null) {
            p.setProperty(KEY_LOG_FILE, logFile.toString());
        }
        if (pid != 0L) {
            p.setProperty(KEY_PID, Long.toString(pid));
        }
        if (note != null) {
            p.setProperty(KEY_NOTE, note);
        }
        try {
            Files.createDirectories(path.getParent());
            Path tmp = path.resolveSibling(
                    path.getFileName() + ".tmp");
            try (OutputStream out = Files.newOutputStream(tmp)) {
                p.store(out,
                        "ike:release-publish Maven Central deploy "
                                + "sentinel (IKE-Network/ike-issues#484)");
            }
            Files.move(tmp, path,
                    java.nio.file.StandardCopyOption.REPLACE_EXISTING,
                    java.nio.file.StandardCopyOption.ATOMIC_MOVE);
        } catch (IOException e) {
            throw new MojoException("Could not write sentinel "
                    + path + ": " + e.getMessage(), e);
        }
    }

    /**
     * Start a new, empty builder.
     *
     * @return a fresh builder
     */
    public static Builder builder() { return new Builder(); }

    /**
     * Start a builder pre-seeded with this sentinel's fields —
     * the typical entry point for the subprocess transitioning
     * {@link State#PENDING} to {@link State#SUCCESS} or
     * {@link State#FAILURE} without restating immutable fields.
     *
     * @return a builder seeded from this sentinel
     */
    public Builder toBuilder() {
        return new Builder()
                .state(state)
                .artifactId(artifactId)
                .version(version)
                .started(started)
                .finished(finished)
                .attempts(attempts)
                .maxAttempts(maxAttempts)
                .lastError(lastError)
                .logFile(logFile)
                .pid(pid)
                .note(note)
                .path(path);
    }

    /**
     * Fluent builder for {@link CentralDeploySentinel}. Required
     * fields ({@code state, artifactId, version, started, path})
     * are validated by {@link #build()}; the rest are optional
     * and default to their type's zero / {@code null}.
     */
    public static final class Builder {
        private State state;
        private String artifactId;
        private String version;
        private Instant started;
        private Instant finished;
        private int attempts;
        private int maxAttempts;
        private String lastError;
        private Path logFile;
        private long pid;
        private String note;
        private Path path;

        /** Creates an empty builder; use {@link #builder()}. */
        Builder() {}

        /**
         * Set the lifecycle state.
         *
         * @param v lifecycle state (must not be {@code null})
         * @return this builder for chaining
         */
        public Builder state(State v) { this.state = v; return this; }

        /**
         * Set the project artifactId.
         *
         * @param v project artifactId
         * @return this builder for chaining
         */
        public Builder artifactId(String v) { this.artifactId = v; return this; }

        /**
         * Set the release version.
         *
         * @param v release version
         * @return this builder for chaining
         */
        public Builder version(String v) { this.version = v; return this; }

        /**
         * Set the start instant.
         *
         * @param v start instant (UTC)
         * @return this builder for chaining
         */
        public Builder started(Instant v) { this.started = v; return this; }

        /**
         * Set the finish instant; pass {@code null} for a
         * sentinel still in {@link State#PENDING}.
         *
         * @param v finish instant, or {@code null} if pending
         * @return this builder for chaining
         */
        public Builder finished(Instant v) { this.finished = v; return this; }

        /**
         * Set the number of attempts taken.
         *
         * @param v attempts taken so far
         * @return this builder for chaining
         */
        public Builder attempts(int v) { this.attempts = v; return this; }

        /**
         * Set the configured maximum attempts.
         *
         * @param v configured max attempts
         * @return this builder for chaining
         */
        public Builder maxAttempts(int v) { this.maxAttempts = v; return this; }

        /**
         * Set the failure summary; pass {@code null} when not failed.
         *
         * @param v failure summary, or {@code null}
         * @return this builder for chaining
         */
        public Builder lastError(String v) { this.lastError = v; return this; }

        /**
         * Set the deploy-log file path.
         *
         * @param v deploy log file path
         * @return this builder for chaining
         */
        public Builder logFile(Path v) { this.logFile = v; return this; }

        /**
         * Set the subprocess PID.
         *
         * @param v subprocess PID, or 0 if not recorded
         * @return this builder for chaining
         */
        public Builder pid(long v) { this.pid = v; return this; }

        /**
         * Set the advisory note; pass {@code null} when none.
         *
         * @param v advisory note, or {@code null}
         * @return this builder for chaining
         */
        public Builder note(String v) { this.note = v; return this; }

        /**
         * Set the sentinel-file path on disk. Required.
         *
         * @param v sentinel file path
         * @return this builder for chaining
         */
        public Builder path(Path v) { this.path = v; return this; }

        /**
         * Validate required fields and build the immutable
         * sentinel value.
         *
         * @return the built immutable sentinel
         * @throws IllegalStateException if any required field
         *         ({@code state, artifactId, version, started,
         *         path}) is unset
         */
        public CentralDeploySentinel build() {
            if (state == null || artifactId == null || version == null
                    || started == null || path == null) {
                throw new IllegalStateException(
                        "Required fields missing — state, artifactId, "
                                + "version, started, and path must be set");
            }
            return new CentralDeploySentinel(this);
        }
    }

    // ── Parse helpers ───────────────────────────────────────────

    private static State parseState(Properties p, Path path) {
        String raw = required(p, KEY_STATE, path);
        try {
            return State.valueOf(raw);
        } catch (IllegalArgumentException e) {
            throw new MojoException("Invalid sentinel state '"
                    + raw + "' in " + path
                    + " (expected PENDING/SUCCESS/FAILURE)", e);
        }
    }

    private static String required(Properties p, String key, Path path) {
        String value = p.getProperty(key);
        if (value == null || value.isBlank()) {
            throw new MojoException("Sentinel " + path
                    + " missing required property '" + key + "'");
        }
        return value;
    }

    private static Instant parseInstant(Properties p, String key,
                                         boolean required, Path path) {
        String raw = p.getProperty(key);
        if (raw == null || raw.isBlank()) {
            if (required) {
                throw new MojoException("Sentinel " + path
                        + " missing required property '" + key + "'");
            }
            return null;
        }
        try {
            return Instant.parse(raw);
        } catch (RuntimeException e) {
            throw new MojoException("Invalid instant '" + raw
                    + "' for property '" + key + "' in " + path, e);
        }
    }

    private static int parseInt(Properties p, String key, int dflt) {
        String raw = p.getProperty(key);
        if (raw == null || raw.isBlank()) {
            return dflt;
        }
        try {
            return Integer.parseInt(raw.trim());
        } catch (NumberFormatException e) {
            return dflt;
        }
    }

    private static long parseLong(Properties p, String key, long dflt) {
        String raw = p.getProperty(key);
        if (raw == null || raw.isBlank()) {
            return dflt;
        }
        try {
            return Long.parseLong(raw.trim());
        } catch (NumberFormatException e) {
            return dflt;
        }
    }

    private static Path parseOptionalPath(Properties p, String key) {
        String raw = p.getProperty(key);
        if (raw == null || raw.isBlank()) {
            return null;
        }
        return Paths.get(raw);
    }
}