rmannibucau commented on issue #8608: URL: https://github.com/apache/hop/issues/8608#issuecomment-5832626222
@hansva maven does it "outside" the asf (but I got a mail that I was not a committer so not possible even if it is the case for apache maven), not sure if anything is ASF owned. Basically it is a curl after the release or a cronjob based on archive to sync it automatically (GH action?. Technically it is just https://sdkman.io/vendors/ . typically something like that in a scheduled action can be sufficient (not tested but you get the idea): ``` import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.regex.Pattern; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import javax.xml.parsers.DocumentBuilderFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; /** * Publishes Apache Hop releases from the Apache archive to SDKMAN! (vendor API * documented at https://sdkman.io/vendors/). * * <p>Each version directory of the archive (https://archive.apache.org/dist/hop/) * contains the archive to publish {@code apache-hop-client-<version>.zip}; the SDKMAN * version is derived from the <b>zip name</b> (e.g. {@code apache-hop-client-1.0.0-incubating.zip} * -> {@code 1.0.0-incubating}) and the official {@code .sha512} sidecar is forwarded as * the SHA-512 checksum of the release. Versions are processed in natural ascending * order ({@code 2.9.0} before {@code 2.18.0}). * * <p>The tool is <b>resumable</b>: each successfully published version is added to the * local state file (--state, one version per line, written after each 2xx), * and versions already visible on SDKMAN * ({@code candidates/<candidate>/linux/versions/all}) are ignored. A failure in * the middle of the run can therefore simply be re-run: only the missing versions * are republished (the vendor POST is itself idempotent). * * <p>Credentials are read from {@code ~/.m2/settings.xml} (a {@code <server>} entry * whose id equals --server-id; {@code <username>} = Consumer-Key, {@code <password>} = * Consumer-Token). A password encrypted by Maven ({@code {...}}) is decrypted * with the master of {@code ~/.m2/settings-security.xml}, exactly as Maven does * (port of the plexus-cipher format: EVP-style SHA-256 derivation, AES/CBC, * master passphrase = {@code "settings.security"}). As a fallback, the environment * variables {@code SDKMAN_CONSUMER_KEY} / {@code SDKMAN_CONSUMER_TOKEN}. * * <p>Usage: {@code java SdkManHopSync.java [options]} (source-file mode, Java 25+), * see {@code --help}. Exit code: 0 = ok, 1 = failure (can be re-run), * 2 = usage or configuration error. */ public final class SdkManHopSync { private static final String DEFAULT_ARCHIVE_URL = "https://archive.apache.org/dist/hop/"; private static final String DEFAULT_CANDIDATE = "hop"; private static final String VENDOR_API = "https://vendors.sdkman.io"; private static final String CANDIDATES_API = "https://api.sdkman.io/2"; private static final String RELEASE_PATH = "/release"; private static final String DEFAULT_PATH = "/default"; private static final String STATE_PLATFORM = "linux"; private static final String MASTER_PASSPHRASE = "settings.security"; private static final String USER_AGENT = "SdkManHopSync/1.0"; private static final String ZIP_PREFIX = "apache-hop-client-"; private static final String ZIP_SUFFIX = ".zip"; private static final int MAX_ATTEMPTS = 3; private static final Pattern HREF = Pattern.compile("<a\\s+href=\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); private static final Pattern VERSION = Pattern.compile("[0-9][A-Za-z0-9._-]*"); private static final HttpClient HTTP = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); private SdkManHopSync() { // utility class, no instance } public static void main(String[] args) { int code = run(args); if (code != 0) { System.exit(code); } } private static int run(String[] args) { Config config; try { config = Config.parse(args); } catch (UsageException e) { System.err.println("error: " + e.getMessage()); System.err.println("run with --help for usage"); return 2; } if (config.help()) { printHelp(); return 0; } try { if (config.verifyOnly()) { Credentials credentials = credentials(config); System.out.println("credentials OK: consumer key " + mask(credentials.key()) + ", consumer token " + mask(credentials.token()) + " (from " + credentials.source() + ")"); return 0; } if (config.debug()) { System.out.println("archive: " + config.archiveUrl()); System.out.println("candidate: " + config.candidate()); System.out.println("vendor API: " + config.vendorApiUrl()); System.out.println("candidates API: " + config.candidatesApiUrl()); System.out.println("settings: " + config.settings()); System.out.println("security settings: " + config.securitySettings()); System.out.println("state file: " + config.stateFile()); System.out.println("platform: " + STATE_PLATFORM); } if (!candidateRegistered(config)) { System.out.println("warn: candidate '" + config.candidate() + "' is not registered on SDKMAN" + " (missing from " + config.candidatesApiUrl() + "/candidates/all)."); System.out.println(" Complete the vendor onboarding first:" + " https://github.com/sdkman/sdkman-cli/wiki/Vendor-onboarding-process"); if (!config.dryRun()) { throw new SyncException("candidate '" + config.candidate() + "' is not registered on SDKMAN;" + " contact [email protected] to onboard the candidate and get your" + " Consumer-Key/Consumer-Token, then re-run"); } } List<HopRelease> releases = discover(config); if (releases.isEmpty()) { System.out.println("no releases found under " + config.archiveUrl()); return 0; } Set<String> local = loadPublished(config); Set<String> remote = remotePublished(config); List<HopRelease> pending = new ArrayList<>(); for (HopRelease release : releases) { if (!local.contains(release.version()) && !remote.contains(release.version())) { pending.add(release); } } if (config.dryRun()) { System.out.println(); System.out.println("DRY-RUN - " + pending.size() + " release(s) would be published:"); for (HopRelease release : pending) { System.out.println(" " + release.version() + " " + release.zipUrl() + (release.sha512() == null ? " (no sha512 checksum)" : "")); } System.out.println("skipped: " + (releases.size() - pending.size()) + " (already published or recorded)"); if (config.setDefault() != null) { if (releases.stream().noneMatch(r -> r.version().equals(config.setDefault()))) { System.out.println("warn: --set-default version '" + config.setDefault() + "' is not among the discovered releases"); } else { System.out.println("would set default for " + config.candidate() + " " + config.setDefault()); } } return 0; } Credentials credentials = credentials(config); int published = 0; int skipped = 0; for (HopRelease release : releases) { if (local.contains(release.version())) { skipped++; continue; } if (remote.contains(release.version())) { skipped++; System.out.println("skip: " + release.version() + " already published on SDKMAN (recording locally)"); recordPublished(config, release.version()); continue; } publish(config, credentials, release); recordPublished(config, release.version()); published++; } if (config.setDefault() != null) { if (releases.stream().noneMatch(r -> r.version().equals(config.setDefault()))) { throw new SyncException("--set-default version '" + config.setDefault() + "' is not among the discovered releases"); } setDefault(config, credentials, config.setDefault()); System.out.println("default set: " + config.candidate() + " " + config.setDefault()); } System.out.println(); System.out.println("done: " + published + " published, " + skipped + " skipped," + " " + releases.size() + " release(s) in the archive"); return 0; } catch (SyncException e) { System.err.println(); System.err.println("error: " + e.getMessage()); System.err.println("the run is resumable: fix the cause if any and re-run;" + " versions already published are skipped"); return 1; } } private static void printHelp() { System.out.println(""" Usage: java SdkManHopSync.java [options] Publishes Apache Hop releases from the Apache distribution archive to SDKMAN!. Each version directory of the archive must contain an apache-hop-client-<version>.zip (the SDKMAN version is derived from the zip filename, e.g. apache-hop-client-1.0.0-incubating.zip -> 1.0.0-incubating); the matching .sha512 sidecar is sent as the SHA-512 checksum of the release. Options: --archive-url <url> archive base URL (default: https://archive.apache.org/dist/hop/) --candidate <name> SDKMAN candidate (default: hop) --server-id <id> id of the <server> holding the SDKMAN credentials in the Maven settings (default: sdkman); <username> is the Consumer-Key and <password> the Consumer-Token. A Maven encrypted <password> ({...}) is decrypted with the master of --security-settings, exactly like Maven does. --settings <path> Maven settings file (default: ~/.m2/settings.xml) --security-settings <path> Maven settings-security file (default: ~/.m2/settings-security.xml) --state <path> local file recording published versions, one per line (default: ~/.cache/sdkman-hop-sync/published.txt) --set-default <version> after the release, set this version as the SDKMAN default --master-passphrase <pwd> passphrase to decrypt the <master> of settings-security when it is itself Maven-encrypted (only needed if the master was created with -Dsettings.security; Maven would prompt for it interactively) --vendor-api-url <url> SDKMAN vendor API base URL (default: https://vendors.sdkman.io) --candidates-api-url <url> SDKMAN public API base URL (default: https://api.sdkman.io/2) --dry-run discover releases and print what would be published; no call to the vendor API, no credentials needed --verify-settings resolve and check the credentials, print a masked summary --debug print the resolved configuration and vendor API responses --help show this help Environment fallback: when no <server> matches --server-id, the SDKMAN_CONSUMER_KEY and SDKMAN_CONSUMER_TOKEN environment variables are used. Example settings.xml server entry: <server> <id>sdkman</id> <username>CONSUMER_KEY</username> <password>{ENCRYPTED_CONSUMER_TOKEN}</password> </server> Behaviour: - versions are processed in natural ascending order (2.9.0 before 2.18.0) - a version already recorded in --state or already visible on SDKMAN (candidates/<candidate>/linux/versions/all) is skipped; the state file is updated right after each successful release, so a failed run can simply be re-run and only the missing versions are published (the vendor POST itself is idempotent) - any rejected request (4xx) or persistent failure aborts the run with a clear message; retries happen on 429/5xx and network errors Exit codes: 0 = ok, 1 = failure (safe to re-run), 2 = usage error. """); } // --------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------- private record Config( String archiveUrl, String candidate, String serverId, String vendorApiUrl, String candidatesApiUrl, Path settings, Path securitySettings, Path stateFile, String setDefault, String masterPassphrase, boolean dryRun, boolean verifyOnly, boolean debug, boolean help) { static Config parse(String... args) throws UsageException { String archiveUrl = DEFAULT_ARCHIVE_URL; String candidate = DEFAULT_CANDIDATE; String serverId = "sdkman"; String vendorApiUrl = VENDOR_API; String candidatesApiUrl = CANDIDATES_API; Path settings = Path.of(resolveHome("~/.m2/settings.xml")); Path securitySettings = Path.of(resolveHome("~/.m2/settings-security.xml")); Path stateFile = Path.of(resolveHome("~/.cache/sdkman-hop-sync/published.txt")); String setDefault = null; String masterPassphrase = null; boolean dryRun = false; boolean verifyOnly = false; boolean debug = false; boolean help = false; for (int i = 0; i < args.length; i++) { String arg = args[i]; switch (arg) { case "--help" -> help = true; case "--dry-run" -> dryRun = true; case "--verify-settings" -> verifyOnly = true; case "--debug" -> debug = true; case "--archive-url" -> archiveUrl = value(args, ++i, arg); case "--candidate" -> candidate = value(args, ++i, arg); case "--server-id" -> serverId = value(args, ++i, arg); case "--vendor-api-url" -> vendorApiUrl = stripTrailingSlash(value(args, ++i, arg)); case "--candidates-api-url" -> candidatesApiUrl = stripTrailingSlash(value(args, ++i, arg)); case "--settings" -> settings = Path.of(resolveHome(value(args, ++i, arg))); case "--security-settings" -> securitySettings = Path.of(resolveHome(value(args, ++i, arg))); case "--state" -> stateFile = Path.of(resolveHome(value(args, ++i, arg))); case "--set-default" -> setDefault = value(args, ++i, arg); case "--master-passphrase" -> masterPassphrase = value(args, ++i, arg); default -> throw new UsageException("unknown option: " + arg); } } return new Config(archiveUrl, candidate, serverId, vendorApiUrl, candidatesApiUrl, settings, securitySettings, stateFile, setDefault, masterPassphrase, dryRun, verifyOnly, debug, help); } private static String stripTrailingSlash(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } private static String value(String[] args, int index, String option) throws UsageException { if (index >= args.length) { throw new UsageException("missing value for " + option); } return args[index]; } } private record Credentials(String key, String token, String source) { } private record HopRelease(String version, String zipUrl, String sha512) { } private static final class UsageException extends Exception { private UsageException(String message) { super(message); } } private static final class SyncException extends Exception { private SyncException(String message) { super(message); } } // --------------------------------------------------------------------- // Credentials (settings.xml + settings-security.xml, decryption // identical to Maven / plexus-cipher) // --------------------------------------------------------------------- private static Credentials credentials(Config config) throws SyncException { String envKey = System.getenv("SDKMAN_CONSUMER_KEY"); String envToken = System.getenv("SDKMAN_CONSUMER_TOKEN"); if (envKey != null || envToken != null) { if (envKey == null || envToken == null || envKey.isBlank() || envToken.isBlank()) { throw new SyncException("SDKMAN_CONSUMER_KEY and SDKMAN_CONSUMER_TOKEN must both be set"); } return new Credentials(envKey, envToken, "environment"); } if (!Files.isRegularFile(config.settings())) { throw new SyncException("Maven settings file not found: " + config.settings() + " (use --settings, or set SDKMAN_CONSUMER_KEY/SDKMAN_CONSUMER_TOKEN)"); } Document settingsDoc = parseXml(config.settings(), "settings"); NodeList servers = settingsDoc.getElementsByTagName("server"); for (int i = 0; i < servers.getLength(); i++) { Element server = (Element) servers.item(i); if (config.serverId().equals(childText(server, "id"))) { String key = childText(server, "username"); String token = childText(server, "password"); if (key == null || token == null || key.isBlank() || token.isBlank()) { throw new SyncException("server '" + config.serverId() + "' in " + config.settings() + " must define <username> (consumer key) and <password> (consumer token)"); } return new Credentials(key, decryptIfNeeded(token, config), "server '" + config.serverId() + "' (" + config.settings() + ")"); } } throw new SyncException("no server with id '" + config.serverId() + "' in " + config.settings() + " (add one, or set SDKMAN_CONSUMER_KEY/SDKMAN_CONSUMER_TOKEN)"); } private static String decryptIfNeeded(String value, Config config) throws SyncException { if (!looksEncrypted(value)) { return value; } String master = loadMaster(config); try { return decryptPlexus(unDecorate(value), master); } catch (GeneralSecurityException e) { throw new SyncException("cannot decrypt the password of server '" + config.serverId() + "' with the master of " + config.securitySettings() + ": " + e.getMessage()); } } private static String loadMaster(Config config) throws SyncException { if (!Files.isRegularFile(config.securitySettings())) { throw new SyncException("the password of server '" + config.serverId() + "' is Maven-encrypted but " + config.securitySettings() + " is missing (create it with: mvn --encrypt-master-password)"); } Document doc = parseXml(config.securitySettings(), "settings-security"); String master = childText(doc.getDocumentElement(), "master"); if (master == null || master.isBlank()) { throw new SyncException("no <master> found in " + config.securitySettings()); } if (!looksEncrypted(master)) { return master; } if (config.masterPassphrase() != null) { try { return decryptPlexus(unDecorate(master), config.masterPassphrase()); } catch (GeneralSecurityException e) { throw new SyncException("cannot decrypt <master> of " + config.securitySettings() + " with --master-passphrase: " + e.getMessage()); } } try { return decryptPlexus(unDecorate(master), MASTER_PASSPHRASE); } catch (GeneralSecurityException e) { throw new SyncException("cannot decrypt <master> of " + config.securitySettings() + ": " + e.getMessage() + " (Maven would prompt for the master passphrase interactively; either pass it with" + " --master-passphrase or re-create it with: mvn --encrypt-master-password)"); } } private static boolean looksEncrypted(String value) { return value.length() > 2 && value.startsWith("{") && value.endsWith("}"); } private static String unDecorate(String value) { return value.substring(1, value.length() - 1); } /** * Faithful port of the plexus-cipher format (PBECipher from plexus-cipher 2.0, * used by Maven for settings.xml): * {@code base64(salt(8) || padLen(1) || ciphertext)}, AES-128 key + IV derived * from SHA-256(password || salt[0..8)) the EVP_BytesToKey way. */ static String decryptPlexus(String base64Value, String passphrase) throws GeneralSecurityException { byte[] all = Base64.getDecoder().decode(base64Value.trim()); if (all.length < 16) { throw new GeneralSecurityException("encrypted payload too short"); } byte[] salt = Arrays.copyOfRange(all, 0, 8); int padLen = all[8] & 0xFF; int payloadLength = all.length - 9 - padLen; if (payloadLength <= 0) { throw new GeneralSecurityException("invalid encrypted payload"); } byte[] encrypted = Arrays.copyOfRange(all, 9, 9 + payloadLength); byte[] keyAndIv = deriveKeyAndIv(passphrase.getBytes(StandardCharsets.UTF_8), salt); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyAndIv, 0, 16, "AES"), new IvParameterSpec(keyAndIv, 16, 16)); return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); } private static byte[] deriveKeyAndIv(byte[] password, byte[] salt) throws GeneralSecurityException { byte[] keyAndIv = new byte[32]; int position = 0; byte[] previous = null; while (position < keyAndIv.length) { MessageDigest digester = MessageDigest.getInstance("SHA-256"); if (previous != null) { digester.update(previous); } digester.update(password); digester.update(salt, 0, 8); previous = digester.digest(); int copied = Math.min(previous.length, keyAndIv.length - position); System.arraycopy(previous, 0, keyAndIv, position, copied); position += copied; } return keyAndIv; } private static Document parseXml(Path file, String label) throws SyncException { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); return factory.newDocumentBuilder().parse(file.toFile()); } catch (Exception e) { throw new SyncException("cannot parse " + label + " file " + file + ": " + e.getMessage()); } } private static String childText(Element parent, String name) { NodeList nodes = parent.getElementsByTagName(name); if (nodes.getLength() == 0) { return null; } String text = nodes.item(0).getTextContent(); return text == null ? null : text.trim(); } // --------------------------------------------------------------------- // Published state (local file + public SDKMAN API) // --------------------------------------------------------------------- private static Set<String> loadPublished(Config config) throws SyncException { if (!Files.isRegularFile(config.stateFile())) { return Set.of(); } try { Set<String> versions = new HashSet<>(); for (String line : Files.readAllLines(config.stateFile(), StandardCharsets.UTF_8)) { String version = line.trim(); if (!version.isEmpty() && !version.startsWith("#")) { versions.add(version); } } return versions; } catch (IOException e) { throw new SyncException("cannot read state file " + config.stateFile() + ": " + e.getMessage()); } } private static void recordPublished(Config config, String version) throws SyncException { try { Path parent = config.stateFile().getParent(); if (parent != null) { Files.createDirectories(parent); } Files.writeString(config.stateFile(), version + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { throw new SyncException("cannot write state file " + config.stateFile() + ": " + e.getMessage()); } } private static Set<String> remotePublished(Config config) { Set<String> versions = new HashSet<>(); try { String csv = getBody(config.candidatesApiUrl() + "/candidates/" + config.candidate() + "/" + STATE_PLATFORM + "/versions/all"); for (String version : csv.split(",")) { if (!version.isBlank()) { versions.add(version.trim()); } } } catch (SyncException e) { System.out.println("warn: cannot query already published versions from SDKMAN (" + e.getMessage() + "), relying on the local state file only"); } return versions; } private static boolean candidateRegistered(Config config) { try { String list = getBody(config.candidatesApiUrl() + "/candidates/all"); return Arrays.stream(list.split(",")).map(String::trim).anyMatch(config.candidate()::equals); } catch (SyncException e) { System.out.println("warn: cannot check candidate registration on SDKMAN (" + e.getMessage() + "), assuming the candidate is registered"); return true; } } // --------------------------------------------------------------------- // Archive discovery // --------------------------------------------------------------------- private static List<HopRelease> discover(Config config) throws SyncException { String base = config.archiveUrl().endsWith("/") ? config.archiveUrl() : config.archiveUrl() + "/"; String index = getBody(base); Set<String> versionsSeen = new HashSet<>(); List<HopRelease> releases = new ArrayList<>(); for (String dir : hrefs(index).stream() .filter(h -> h.endsWith("/")) .filter(h -> !h.startsWith("/") && !h.startsWith("?") && !h.startsWith("http")) .map(h -> h.substring(0, h.length() - 1)) .filter(h -> !h.isEmpty()) .sorted(SdkManHopSync::compareNatural) .toList()) { String dirUrl = base + dir + "/"; HttpResponse<String> page = get(dirUrl); if (page.statusCode() != 200) { System.out.println("warn: cannot list " + dirUrl + " (HTTP " + page.statusCode() + "), skipping"); continue; } String zip = hrefs(page.body()).stream() .filter(f -> f.startsWith(ZIP_PREFIX) && f.endsWith(ZIP_SUFFIX)) .findFirst() .orElse(null); if (zip == null) { System.out.println("warn: no " + ZIP_PREFIX + "<version>" + ZIP_SUFFIX + " in " + dirUrl + ", skipping"); continue; } String version = zip.substring(ZIP_PREFIX.length(), zip.length() - ZIP_SUFFIX.length()); if (!VERSION.matcher(version).matches()) { System.out.println("warn: invalid version derived from " + zip + ", skipping"); continue; } if (!versionsSeen.add(version)) { System.out.println("warn: duplicate version " + version + " (from " + dirUrl + "), keeping the first one"); continue; } String sha512 = null; HttpResponse<String> shaResponse = get(dirUrl + zip + ".sha512"); if (shaResponse.statusCode() == 200) { sha512 = parseSha512(shaResponse.body()); if (sha512 == null) { System.out.println("warn: cannot parse the sha512 sidecar of " + zip + ", publishing without checksum"); } } else { System.out.println("warn: no sha512 sidecar for " + zip + " (HTTP " + shaResponse.statusCode() + "), publishing without checksum"); } releases.add(new HopRelease(version, dirUrl + zip, sha512)); } return releases; } private static List<String> hrefs(String html) { List<String> out = new ArrayList<>(); var matcher = HREF.matcher(html); while (matcher.find()) { out.add(matcher.group(1)); } return out; } private static String parseSha512(String content) { String first = content.trim().split("\\s+")[0]; boolean hex = first.length() == 128 && first.chars().allMatch(c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')); return hex ? first.toLowerCase() : null; } /** Natural sort: numeric segments compare numerically (2.9.0 < 2.18.0). */ private static int compareNatural(String left, String right) { int i = 0; int j = 0; while (i < left.length() && j < right.length()) { char lc = left.charAt(i); char rc = right.charAt(j); if (Character.isDigit(lc) && Character.isDigit(rc)) { int startLeft = i; int startRight = j; while (i < left.length() && Character.isDigit(left.charAt(i))) { i++; } while (j < right.length() && Character.isDigit(right.charAt(j))) { j++; } String numberLeft = stripZeros(left.substring(startLeft, i)); String numberRight = stripZeros(right.substring(startRight, j)); int compared = numberLeft.length() != numberRight.length() ? Integer.compare(numberLeft.length(), numberRight.length()) : numberLeft.compareTo(numberRight); if (compared != 0) { return compared; } } else { int compared = Character.compare(lc, rc); if (compared != 0) { return compared; } i++; j++; } } return Integer.compare(left.length(), right.length()); } private static String stripZeros(String number) { int firstNonZero = 0; while (firstNonZero < number.length() - 1 && number.charAt(firstNonZero) == '0') { firstNonZero++; } return number.substring(firstNonZero); } // --------------------------------------------------------------------- // Publication sur l'API vendor SDKMAN // --------------------------------------------------------------------- private static void publish(Config config, Credentials credentials, HopRelease release) throws SyncException { System.out.println("publishing " + config.candidate() + " " + release.version() + " from " + release.zipUrl()); String response = sendJson("POST", config.vendorApiUrl() + RELEASE_PATH, credentials, json(config.candidate(), release.version(), release.zipUrl(), release.sha512())); if (config.debug()) { System.out.println(" vendor API response: " + trimTo(response, 500)); } } private static void setDefault(Config config, Credentials credentials, String version) throws SyncException { String response = sendJson("PUT", config.vendorApiUrl() + DEFAULT_PATH, credentials, json(config.candidate(), version, null, null)); if (config.debug()) { System.out.println(" vendor API response: " + trimTo(response, 500)); } } private static String json(String candidate, String version, String url, String sha512) { StringBuilder builder = new StringBuilder(); builder.append("{\"candidate\":\"").append(jsonEscape(candidate)) .append("\",\"version\":\"").append(jsonEscape(version)).append('"'); if (url != null) { builder.append(",\"url\":\"").append(jsonEscape(url)).append('"'); } if (sha512 != null) { builder.append(",\"checksums\":{\"SHA-512\":\"").append(sha512).append("\"}"); } return builder.append('}').toString(); } private static String jsonEscape(String value) { return value.replace("\\", "\\\\").replace("\"", "\\\""); } private static String sendJson(String method, String url, Credentials credentials, String json) throws SyncException { HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofMinutes(5)) .header("Consumer-Key", credentials.key()) .header("Consumer-Token", credentials.token()) .header("Content-Type", "application/json") .header("Accept", "application/json") .header("User-Agent", USER_AGENT); HttpRequest request = switch (method) { case "POST" -> builder.POST(HttpRequest.BodyPublishers.ofString(json)).build(); case "PUT" -> builder.PUT(HttpRequest.BodyPublishers.ofString(json)).build(); default -> throw new SyncException("unsupported method " + method); }; SyncException last = null; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); int status = response.statusCode(); if (status >= 200 && status < 300) { return response.body(); } String detail = trimTo(response.body(), 400); if (status == 429 || status >= 500) { if (attempt < MAX_ATTEMPTS) { System.out.println(" got HTTP " + status + " (attempt " + attempt + "/" + MAX_ATTEMPTS + "), retrying in " + backoffSeconds(attempt) + "s"); sleepMillis(backoffSeconds(attempt) * 1000L); continue; } throw new SyncException("SDKMAN vendor API error after " + MAX_ATTEMPTS + " attempts (HTTP " + status + "): " + detail); } throw new SyncException("SDKMAN vendor API rejected the request (HTTP " + status + "): " + detail); } catch (SyncException e) { throw e; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SyncException("interrupted"); } catch (IOException e) { last = new SyncException("HTTP " + method + " " + url + " failed: " + e.getMessage()); if (attempt < MAX_ATTEMPTS) { sleepMillis(backoffSeconds(attempt) * 1000L); } } } throw last; } private static HttpResponse<String> get(String url) throws SyncException { HttpRequest request = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofMinutes(2)) .header("User-Agent", USER_AGENT) .GET() .build(); SyncException last = null; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); int status = response.statusCode(); if (status == 429 || status >= 500) { if (attempt < MAX_ATTEMPTS) { sleepMillis(backoffSeconds(attempt) * 1000L); continue; } } return response; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SyncException("interrupted"); } catch (IOException e) { last = new SyncException("GET " + url + " failed: " + e.getMessage()); if (attempt < MAX_ATTEMPTS) { sleepMillis(backoffSeconds(attempt) * 1000L); } } } throw last; } private static String getBody(String url) throws SyncException { HttpResponse<String> response = get(url); if (response.statusCode() != 200) { throw new SyncException("GET " + url + " returned HTTP " + response.statusCode()); } return response.body(); } // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- private static String mask(String value) { if (value == null || value.isEmpty()) { return "(empty)"; } if (value.length() <= 6) { return "****"; } return value.substring(0, 3) + "..." + value.substring(value.length() - 3); } private static String trimTo(String value, int max) { if (value == null) { return ""; } String trimmed = value.trim(); if (trimmed.length() <= max) { return trimmed; } return trimmed.substring(0, max) + "..."; } private static long backoffSeconds(int attempt) { return attempt == 1 ? 5 : 15; } private static void sleepMillis(long milliseconds) throws SyncException { try { Thread.sleep(milliseconds); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SyncException("interrupted"); } } private static String resolveHome(String path) { return path.startsWith("~/") ? System.getProperty("user.home") + path.substring(1) : path; } } ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
