This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26748-self-update-nightly in repository https://gitbox.apache.org/repos/asf/camel.git
commit 6e4ccb2344fbf5f0d31993f51f2a127cb8ea2425 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Nov 8 23:24:41 2026 +0100 CAMEL-26748: camel-jbang - self-update and install.sh: switch to a nightly SNAPSHOT build of the Camel CLI camel self-update --version SNAPSHOT (main) or X.Y.Z-SNAPSHOT installs the newest nightly build of the launcher from the Apache snapshot repository, and pins it, so a later camel self-update follows the nightly builds. An explicit release --version goes back to the releases. install.sh takes the same values. - each nightly build is installed in its own directory, named after it (4.24.0-20261108.143653-24), so a running command keeps its files; the newest three are kept, releases are never removed - the build is resolved from maven-metadata.xml (Maven 3 and the Maven 2 form Nexus 2 serves to the JDK HTTP client), and checked against a strict pattern before it is used in a directory name or URL; DTDs are disabled - verified against the SHA-1 next to it in the snapshot repository, with a note that it is a development build, not an Apache release - camel version shows the nightly build and when it was built - no update notice for a pin to nightly builds install.ps1 does not install nightly builds yet. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-launcher-install.adoc | 46 ++++ .../jbang/core/commands/version/VersionGet.java | 11 + .../dsl/jbang/core/common/InstallDetector.java | 61 ++++++ .../core/commands/version/VersionGetTest.java | 24 ++ .../dsl/jbang/core/common/InstallDetectorTest.java | 26 +++ .../camel-launcher/src/install/install.sh | 185 +++++++++++++++- .../jbang/launcher/selfupdate/ManifestFetcher.java | 145 +++++++++++- .../dsl/jbang/launcher/selfupdate/Nightly.java | 121 +++++++++++ .../camel/dsl/jbang/launcher/selfupdate/Pin.java | 34 ++- .../launcher/selfupdate/SelfUpdateCommand.java | 97 ++++++++- .../jbang/launcher/selfupdate/UpdateChecker.java | 3 +- .../jbang/launcher/SelfUpdateIntegrationTest.java | 117 +++++++++- .../dsl/jbang/launcher/WebsiteInstallTest.java | 242 ++++++++++++++++++++- .../jbang/launcher/WebsiteInstallerFixture.java | 5 + .../launcher/selfupdate/ManifestFetcherTest.java | 153 +++++++++++++ .../dsl/jbang/launcher/selfupdate/NightlyTest.java | 77 +++++++ .../dsl/jbang/launcher/selfupdate/PinTest.java | 45 +++- 17 files changed, 1360 insertions(+), 32 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc index fdffff12e6f5..1b236621d50b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc @@ -127,6 +127,52 @@ one (for example after a `--version LTS` install that resolved to 4.22.2), `came with a "pinned to version LTS" message. Re-run the installer with `--version LTS` (or `--version 4.22`) to update instead. +== Nightly development builds + +Apache publishes a nightly build of the Camel CLI to the Apache snapshot repository, for main and for the +maintenance branches. It lets you try the latest development code, or a fix before its patch release, without +building Camel yourself. + +IMPORTANT: A nightly build is a development build for testing, not an Apache release. Do not use it in production. + +[source,bash] +---- +camel self-update --version SNAPSHOT # the newest nightly build of main +camel self-update --version 4.22.3-SNAPSHOT # the newest nightly build of a maintenance branch +camel self-update --version LTS # back to a release (or --version X.Y.Z) +---- + +The installer takes the same values: + +[source,bash] +---- +curl -fsSL https://camel.apache.org/install.sh | sh -s -- --version SNAPSHOT +---- + +`SNAPSHOT` and `X.Y.Z-SNAPSHOT` are recorded as the pin, so a later `camel self-update` installs the newest nightly +build of main, or of that version. Installing a release with `--version` (or running the installer without +`--version`) leaves the nightly builds and goes back to the releases. A pin to nightly builds gets no update notice, as +a new build lands every day. + +Each nightly build is installed in its own directory, named after it, such as +`versions/4.24.0-20261108.143653-24` (the version, the UTC time it was built, and the build number). A command that +still runs from the previous build keeps its files. The installer keeps the three newest nightly builds and removes the +older ones; it never removes a release. `camel version` shows which nightly build runs, and when it was built: + +[source] +---- +$ camel version +Camel CLI version: 4.24.0-SNAPSHOT +Nightly build: 4.24.0-20261108.143653-24 (built 2026-11-08 14:36:53 UTC) +---- + +A release is verified against a SHA-256 recorded in a manifest on the Camel website, a different server than the one +it is downloaded from. A nightly build is verified only against the SHA-1 published next to it in the snapshot +repository: this catches a broken download, not a tampered one. + +NOTE: Installing nightly builds needs a Camel CLI and installer that support them. The Windows installer +(`install.ps1`) does not install nightly builds yet. + == Requirements * A Java 21+ runtime available at install and run time. The launcher locates Java from diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java index 386992207be5..6e75e8a68020 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java @@ -16,11 +16,14 @@ */ package org.apache.camel.dsl.jbang.core.commands.version; +import java.time.format.DateTimeFormatter; + import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.InstallDetector; import org.apache.camel.dsl.jbang.run.common.VersionHelper; import picocli.CommandLine; @@ -49,6 +52,14 @@ public class VersionGet extends CamelCommand { CamelCatalog catalog = new DefaultCamelCatalog(); String v = catalog.getCatalogVersion(); printer().println("Camel CLI version: " + v); + // a nightly build has a SNAPSHOT version: the build tells which one, and when it was built + InstallDetector.runningNightlyBuild().ifPresent(build -> printer().println("Nightly build: " + build + + InstallDetector.nightlyBuildTime(build) + .map(t -> " (built " + t.format( + DateTimeFormatter.ofPattern( + "yyyy-MM-dd HH:mm:ss")) + + " UTC)") + .orElse(""))); CommandLineHelper.loadProperties(properties -> { String uv = properties.getProperty("camel-version"); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java index be4b6e5159ce..f37863a39e4d 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java @@ -22,11 +22,18 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Detects where the currently running Camel CLI launcher lives, and discovers other Camel CLI installations on the same @@ -55,9 +62,63 @@ public final class InstallDetector { private static final boolean IS_WINDOWS = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("windows"); + // A nightly build as the Apache snapshot repository names it, which the web installer uses as its directory name: + // X.Y.Z-YYYYMMDD.HHMMSS-N, the UTC timestamp and build number of the deploy. + private static final Pattern NIGHTLY_BUILD + = Pattern.compile("[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]{8}\\.[0-9]{6})-[0-9]{1,9}"); + private static final DateTimeFormatter NIGHTLY_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss"); + private InstallDetector() { } + /** + * The nightly build the running launcher was installed as by the web installer ({@code camel self-update --version + * SNAPSHOT}), such as {@code 4.24.0-20261108.143653-24}: the name of its directory under + * {@link #webInstallerVersionsRoot()}, when that is a nightly build. + */ + public static Optional<String> runningNightlyBuild() { + String jarPath = System.getProperty("camel.launcher.jar"); + if (jarPath == null || jarPath.isBlank()) { + return Optional.empty(); + } + return nightlyBuildOf(Paths.get(jarPath), webInstallerVersionsRoot()); + } + + /** + * The nightly build a launcher JAR belongs to: the name of its directory under the web installer's + * {@code versionsRoot}, when that is a nightly build ({@code X.Y.Z-YYYYMMDD.HHMMSS-N}). + */ + public static Optional<String> nightlyBuildOf(Path launcherJar, Path versionsRoot) { + if (launcherJar == null) { + return Optional.empty(); + } + Path jar = launcherJar.toAbsolutePath().normalize(); + Path root = versionsRoot.toAbsolutePath().normalize(); + if (!jar.startsWith(root) || jar.getNameCount() <= root.getNameCount()) { + return Optional.empty(); + } + String dir = jar.getName(root.getNameCount()).toString(); + return isNightlyBuild(dir) ? Optional.of(dir) : Optional.empty(); + } + + /** Whether {@code value} is a nightly build, {@code X.Y.Z-YYYYMMDD.HHMMSS-N}. */ + public static boolean isNightlyBuild(String value) { + return value != null && NIGHTLY_BUILD.matcher(value).matches(); + } + + /** When a nightly build was deployed to the snapshot repository (its timestamp is UTC). */ + public static Optional<ZonedDateTime> nightlyBuildTime(String build) { + Matcher m = build != null ? NIGHTLY_BUILD.matcher(build) : null; + if (m == null || !m.matches()) { + return Optional.empty(); + } + try { + return Optional.of(LocalDateTime.parse(m.group(1), NIGHTLY_TIMESTAMP).atZone(ZoneOffset.UTC)); + } catch (DateTimeParseException e) { + return Optional.empty(); + } + } + /** * Determines where the currently running launcher lives, by resolving the path of the JAR backing this JVM process * (set as the {@code camel.launcher.jar} system property by {@code CamelLauncher.main()}). diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java index 7e32eb22b145..3a0f73f413c3 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java @@ -26,6 +26,7 @@ import org.apache.camel.dsl.jbang.core.commands.CamelCommandBaseTestSupport; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.commands.UserConfigHelper; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.InstallDetector; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -88,6 +89,29 @@ class VersionGetTest extends CamelCommandBaseTestSupport { Assertions.assertEquals("kamelets-version = 4.18.0", lines.get(3)); } + @Test + public void shouldPrintTheNightlyBuildAndWhenItWasBuilt() throws Exception { + UserConfigHelper.createUserConfig(""); + Path versions = InstallDetector.webInstallerVersionsRoot(); + String saved = System.getProperty("camel.launcher.jar"); + System.setProperty("camel.launcher.jar", + versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar").toString()); + try { + VersionGet command = createCommand(); + command.doCall(); + } finally { + if (saved != null) { + System.setProperty("camel.launcher.jar", saved); + } else { + System.clearProperty("camel.launcher.jar"); + } + } + + List<String> lines = printer.getLines(); + Assertions.assertTrue(lines.get(0).startsWith("Camel CLI version:")); + Assertions.assertEquals("Nightly build: 4.24.0-20261108.143653-24 (built 2026-11-08 14:36:53 UTC)", lines.get(1)); + } + private void createJBangVersionFile(String version) throws IOException { Path jBangDir = CommandLineHelper.getHomeDir().resolve(".jbang/cache"); if (!jBangDir.toFile().exists()) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java index 8e14e3e2cd01..d6f9ed743bf3 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java @@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.common; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import org.apache.camel.util.FileUtil; import org.junit.jupiter.api.Test; @@ -183,6 +185,30 @@ class InstallDetectorTest { assertThat(InstallDetector.followLinks(a)).isIn(a, b); } + @Test + void nightlyBuildIsTheVersionDirectoryOfTheLauncher() { + Path versions = Path.of("/home/user/.local/share/camel-cli/versions"); + + assertThat(InstallDetector.nightlyBuildOf( + versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar"), versions)) + .contains("4.24.0-20261108.143653-24"); + assertThat(InstallDetector.nightlyBuildOf(versions.resolve("4.23.0/lib/camel-launcher-4.23.0.jar"), versions)) + .isEmpty(); + assertThat(InstallDetector.nightlyBuildOf(Path.of("/opt/camel/lib/camel-launcher.jar"), versions)).isEmpty(); + assertThat(InstallDetector.nightlyBuildOf(versions, versions)).isEmpty(); + assertThat(InstallDetector.nightlyBuildOf(null, versions)).isEmpty(); + } + + @Test + void nightlyBuildTimeIsTheUtcDeployTimestamp() { + assertThat(InstallDetector.nightlyBuildTime("4.24.0-20261108.143653-24")) + .contains(ZonedDateTime.of(2026, 11, 8, 14, 36, 53, 0, ZoneOffset.UTC)); + // not a valid date or time + assertThat(InstallDetector.nightlyBuildTime("4.24.0-20261308.143653-24")).isEmpty(); + assertThat(InstallDetector.nightlyBuildTime("4.24.0")).isEmpty(); + assertThat(InstallDetector.nightlyBuildTime(null)).isEmpty(); + } + @Test void resolveActiveOnPathReturnsEmptyWhenNotFound() { var result = InstallDetector.resolveActiveOnPath("/nonexistent/dir-a" + File.pathSeparator + "/nonexistent/dir-b"); diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.sh b/dsl/camel-jbang/camel-launcher/src/install/install.sh index 9b6f401e9076..d2b97d5607f9 100755 --- a/dsl/camel-jbang/camel-launcher/src/install/install.sh +++ b/dsl/camel-jbang/camel-launcher/src/install/install.sh @@ -21,6 +21,7 @@ set -eu # Test seams only: production installs never set these, so the defaults below are always used. manifest_base_url="${CAMEL_INSTALL_MANIFEST_BASE_URL:-https://camel.apache.org/camel-cli/releases}" maven_base_url="${CAMEL_INSTALL_MAVEN_BASE_URL:-https://repo1.maven.org/maven2/org/apache/camel/camel-launcher}" +snapshot_base_url="${CAMEL_INSTALL_SNAPSHOT_BASE_URL:-https://repository.apache.org/content/repositories/snapshots/org/apache/camel/camel-launcher}" ca_cert="${CAMEL_INSTALL_CA_CERT:-}" data_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions" @@ -59,8 +60,38 @@ is_valid_line() { esac } -# Classifies a --version value as an exact release (X.Y.Z), a release line (X.Y) or the newest LTS release -# (LTS, case-insensitive). Sets $selector_kind and normalises $requested_version ('lts' becomes 'LTS'). +# A snapshot version: X.Y.Z-SNAPSHOT. +is_valid_snapshot() { + case "$1" in + *-SNAPSHOT) is_valid_version "${1%-SNAPSHOT}" ;; + *) return 1 ;; + esac +} + +# One nightly build, as the snapshot repository names it: X.Y.Z-YYYYMMDD.HHMMSS-N. The value ends up in a directory +# name and a download URL, so it is checked character by character. +is_valid_nightly() { + nb_value="$1" + nb_base="${nb_value%%-*}" + nb_rest="${nb_value#*-}" + [ "$nb_rest" != "$nb_value" ] || return 1 + is_valid_version "$nb_base" || return 1 + nb_number="${nb_rest##*-}" + nb_stamp="${nb_rest%-*}" + case "$nb_stamp" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]) ;; + *) return 1 ;; + esac + case "$nb_number" in + '' | *[!0-9]*) return 1 ;; + esac + [ ${#nb_number} -le 9 ] +} + +# Classifies a --version value as an exact release (X.Y.Z), a release line (X.Y), the newest LTS release (LTS, +# case-insensitive), the nightly builds of main (SNAPSHOT, case-insensitive) or of a snapshot version +# (X.Y.Z-SNAPSHOT), or one nightly build (X.Y.Z-YYYYMMDD.HHMMSS-N). Sets $selector_kind and normalises +# $requested_version ('lts' becomes 'LTS', 'snapshot' becomes 'SNAPSHOT'). is_valid_selector() { case "$requested_version" in [Ll][Tt][Ss]) @@ -68,7 +99,20 @@ is_valid_selector() { selector_kind="lts" return 0 ;; + [Ss][Nn][Aa][Pp][Ss][Hh][Oo][Tt]) + requested_version="SNAPSHOT" + selector_kind="snapshot" + return 0 + ;; esac + if is_valid_snapshot "$requested_version"; then + selector_kind="snapshot" + return 0 + fi + if is_valid_nightly "$requested_version"; then + selector_kind="nightly" + return 0 + fi if is_valid_version "$requested_version"; then selector_kind="exact" return 0 @@ -109,6 +153,19 @@ fetch() { fi } +sha1() { + file="$1" + if command -v sha1sum >/dev/null 2>&1; then + sha1sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 1 "$file" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha1 "$file" | awk '{print $NF}' + else + fail "sha1sum, shasum, or openssl is required to verify the download" + fi +} + sha256() { file="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -252,6 +309,112 @@ activate() { mv -f "$tmp_link" "$bin_dir/camel" } +# The snapshot version of main: the highest X.Y.Z-SNAPSHOT listed in the snapshot repository metadata (the +# maintenance branches publish nightly builds too, under lower versions). Maven writes one element per line. +latest_snapshot_version() { + sed -n 's|.*<version>\([^<]*\)</version>.*|\1|p' "$1" | while IFS= read -r lsv; do + is_valid_snapshot "$lsv" && printf '%s\n' "${lsv%-SNAPSHOT}" + done | sort -t. -n -k1,1 -k2,2 -k3,3 | tail -1 +} + +# The newest nightly build of a snapshot version ($2, X.Y.Z) from its metadata ($1): the <value> of the bin tar.gz +# <snapshotVersion> (Maven 3 metadata), or else the <timestamp>-<buildNumber> of the <snapshot> (Maven 2 metadata, +# which Nexus 2 serves to a client it does not take for Maven 3), as all the files of one deploy share them. +newest_nightly() { + awk -v base="$2" ' + /<snapshotVersion>/ { inside = 1; c = ""; e = ""; v = "" } + inside && /<classifier>/ { c = $0; gsub(/.*<classifier>|<\/classifier>.*/, "", c) } + inside && /<extension>/ { e = $0; gsub(/.*<extension>|<\/extension>.*/, "", e) } + inside && /<value>/ { v = $0; gsub(/.*<value>|<\/value>.*/, "", v) } + /<\/snapshotVersion>/ { if (c == "bin" && e == "tar.gz") { found = v } inside = 0 } + /<timestamp>/ && ts == "" { ts = $0; gsub(/.*<timestamp>|<\/timestamp>.*/, "", ts) } + /<buildNumber>/ && bn == "" { bn = $0; gsub(/.*<buildNumber>|<\/buildNumber>.*/, "", bn) } + END { + if (found != "") { print found } else if (ts != "" && bn != "") { print base "-" ts "-" bn } + } + ' "$1" +} + +# Removes all but the newest three nightly builds, never the one just installed: a command that still runs from one +# of them keeps its files. Releases are never removed. +prune_nightlies() { + keep_build="$1" + ls "$data_root" | while IFS= read -r pn_dir; do + is_valid_nightly "$pn_dir" && printf '%s %s\n' "${pn_dir#*-}" "$pn_dir" + done | sort -r | awk 'NR > 3 { print $2 }' | while IFS= read -r pn_old; do + [ "$pn_old" = "$keep_build" ] || rm -rf "${data_root:?}/$pn_old" + done +} + +# Installs a nightly build from the snapshot repository, verified against the SHA-1 published next to it there: a +# nightly build is no Apache release, so there is no manifest on camel.apache.org for it. +install_nightly() { + case "$requested_version" in + SNAPSHOT) + metadata_file="$staging_dir/metadata.xml" + fetch "$snapshot_base_url/maven-metadata.xml" "$metadata_file" \ + || fail "failed to download the snapshot metadata from $snapshot_base_url/maven-metadata.xml" + snapshot_base="$(latest_snapshot_version "$metadata_file")" + [ -n "$snapshot_base" ] || fail "the snapshot repository lists no X.Y.Z-SNAPSHOT version" + snapshot_version="$snapshot_base-SNAPSHOT" + ;; + *-SNAPSHOT) + snapshot_version="$requested_version" + ;; + *) + snapshot_version="${requested_version%%-*}-SNAPSHOT" + nightly="$requested_version" + ;; + esac + if [ -z "${nightly:-}" ]; then + metadata_file="$staging_dir/$snapshot_version-metadata.xml" + fetch "$snapshot_base_url/$snapshot_version/maven-metadata.xml" "$metadata_file" \ + || fail "no nightly builds of $snapshot_version at $snapshot_base_url/$snapshot_version/maven-metadata.xml" + nightly="$(newest_nightly "$metadata_file" "${snapshot_version%-SNAPSHOT}")" + fi + is_valid_nightly "$nightly" && [ "${nightly%%-*}-SNAPSHOT" = "$snapshot_version" ] \ + || fail "the snapshot metadata names an invalid nightly build of $snapshot_version: $nightly" + + # camel self-update printed the note already + if [ "${CAMEL_INSTALL_SELF_UPDATE:-}" != "true" ]; then + echo "Note: $nightly is a nightly development build of Apache Camel, for testing only. It is not an Apache" \ + "release, and is verified only against the SHA-1 published next to it in the snapshot repository." + fi + + archive_url="$snapshot_base_url/$snapshot_version/camel-launcher-$nightly-bin.tar.gz" + archive_file="$staging_dir/camel-launcher-$nightly-bin.tar.gz" + fetch "$archive_url" "$archive_file" || fail "failed to download archive from $archive_url" + sha1_file="$staging_dir/archive.sha1" + fetch "$archive_url.sha1" "$sha1_file" || fail "failed to download checksum from $archive_url.sha1" + expected_sha1="$(awk '{ print $1; exit }' "$sha1_file")" + [ ${#expected_sha1} -eq 40 ] || fail "published SHA-1 is not a 40-character lowercase hex value" + case "$expected_sha1" in + *[!0-9a-f]*) fail "published SHA-1 is not a 40-character lowercase hex value" ;; + esac + [ "$(sha1 "$archive_file")" = "$expected_sha1" ] || fail "checksum mismatch for downloaded archive" + + # The archive of a nightly build unpacks to camel-launcher-X.Y.Z-SNAPSHOT. + validate_tar "$archive_file" "$snapshot_version" + extract_dir="$staging_dir/extract" + mkdir -p "$extract_dir" + tar -xzf "$archive_file" -C "$extract_dir" + staged_root_dir="$extract_dir/camel-launcher-$snapshot_version" + verify_staged "$staged_root_dir/bin/camel.sh" + + activate "$nightly" "$staged_root_dir" + update_pin_state + prune_nightlies "$nightly" + print_path_hint + echo "Installed Camel CLI $nightly (a nightly build of $snapshot_version) to $data_root/$nightly" +} + +print_path_hint() { + case ":$PATH:" in + *":$bin_dir:"*) ;; + *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;; + esac +} + # Records whether this install is pinned, so `camel self-update` can honour it: an exact X.Y.Z pin refuses to # move, an X.Y or LTS pin only moves within that selector. The selector the user asked for is written, not the # version it resolved to, so an exact pin is unchanged and a selector pin keeps tracking its alias. Skipped when @@ -267,7 +430,7 @@ update_pin_state() { fi } -usage="Usage: install.sh [--version X.Y.Z|X.Y|LTS]" +usage="Usage: install.sh [--version X.Y.Z|X.Y|LTS|X.Y.Z-SNAPSHOT|SNAPSHOT]" requested_version="" selector_kind="" while [ $# -gt 0 ]; do @@ -284,7 +447,8 @@ while [ $# -gt 0 ]; do done if [ -n "$requested_version" ]; then - is_valid_selector || fail "invalid --version value: $requested_version (expected X.Y.Z, X.Y or LTS)" + is_valid_selector \ + || fail "invalid --version value: $requested_version (expected X.Y.Z, X.Y, LTS, X.Y.Z-SNAPSHOT or SNAPSHOT)" fi staging_dir="$camel_cli_root/.staging.$$" @@ -297,6 +461,13 @@ mkdir -p "$camel_cli_root" rm -rf "$staging_dir" mkdir -m 700 "$staging_dir" +case "$selector_kind" in + snapshot | nightly) + install_nightly + exit 0 + ;; +esac + case "$selector_kind" in lts) manifest_url="$manifest_base_url/lts.properties" ;; exact | line) manifest_url="$manifest_base_url/$requested_version.properties" ;; @@ -349,10 +520,6 @@ verify_staged "$staged_root_dir/bin/camel.sh" activate "$version" "$staged_root_dir" update_pin_state - -case ":$PATH:" in - *":$bin_dir:"*) ;; - *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;; -esac +print_path_hint echo "Installed Camel CLI $version to $data_root/$version" diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java index 8c6c8abe585f..0db8f4f0028f 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.launcher.selfupdate; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.ProxySelector; import java.net.URI; @@ -30,16 +31,31 @@ import java.util.List; import java.util.Map; import java.util.Set; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import org.apache.camel.dsl.jbang.run.common.VersionHelper; + /** * Fetches and parses the website-installer manifest ({@code latest.properties}/{@code X.Y.Z.properties}). Mirrors * {@code install.sh}'s {@code parse_manifest} exactly, in Java. Used only for the pre-flight version compare that * neither {@code install.sh} nor {@code install.ps1} perform on their own -- the actual archive download, checksum * verification, and extraction happen inside whichever of those two scripts {@code SelfUpdateCommand} delegates to. + * <p> + * For the nightly builds (see {@link Nightly}) it reads the {@code maven-metadata.xml} of the Apache snapshot + * repository instead, the same files {@code install.sh} resolves them from. */ public final class ManifestFetcher implements AutoCloseable { private static final String DEFAULT_MANIFEST_BASE_URL = "https://camel.apache.org/camel-cli/releases"; private static final String DEFAULT_MAVEN_BASE_URL = "https://repo1.maven.org/maven2/org/apache/camel/camel-launcher"; + private static final String DEFAULT_SNAPSHOT_BASE_URL + = "https://repository.apache.org/content/repositories/snapshots/org/apache/camel/camel-launcher"; private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); public record Manifest(String version, String tarSha256, String zipSha256) { @@ -47,10 +63,16 @@ public final class ManifestFetcher implements AutoCloseable { private final String manifestBaseUrl; private final String mavenBaseUrl; + private final String snapshotBaseUrl; private final HttpClient httpClient; public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl) { - this(manifestBaseUrl, mavenBaseUrl, HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build()); + this(manifestBaseUrl, mavenBaseUrl, DEFAULT_SNAPSHOT_BASE_URL); + } + + public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, String snapshotBaseUrl) { + this(manifestBaseUrl, mavenBaseUrl, snapshotBaseUrl, + HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build()); } // Visible for testing: SelfUpdateIntegrationTest injects an HttpClient whose SSLContext trusts the @@ -58,22 +80,30 @@ public final class ManifestFetcher implements AutoCloseable { // (used by the two-arg constructor above) has no way to know about a test-only CA. Production callers // only ever use the two-arg constructor. public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, HttpClient httpClient) { + this(manifestBaseUrl, mavenBaseUrl, DEFAULT_SNAPSHOT_BASE_URL, httpClient); + } + + public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, String snapshotBaseUrl, HttpClient httpClient) { this.manifestBaseUrl = manifestBaseUrl; this.mavenBaseUrl = mavenBaseUrl; + this.snapshotBaseUrl = snapshotBaseUrl; this.httpClient = httpClient; } /** - * Production wiring: {@code CAMEL_SELF_UPDATE_MANIFEST_BASE_URL}/{@code CAMEL_SELF_UPDATE_MAVEN_BASE_URL} override - * the defaults, mirroring {@code install.sh}'s {@code CAMEL_INSTALL_*} test seams under a distinct name so - * overriding one never accidentally affects the other. Production installs never set either. + * Production wiring: {@code CAMEL_SELF_UPDATE_MANIFEST_BASE_URL}/{@code CAMEL_SELF_UPDATE_MAVEN_BASE_URL}/ + * {@code CAMEL_SELF_UPDATE_SNAPSHOT_BASE_URL} override the defaults, mirroring {@code install.sh}'s + * {@code CAMEL_INSTALL_*} test seams under a distinct name so overriding one never accidentally affects the other. + * Production installs never set either. */ public static ManifestFetcher fromEnvironment() { String manifestBaseUrl = System.getenv().getOrDefault("CAMEL_SELF_UPDATE_MANIFEST_BASE_URL", DEFAULT_MANIFEST_BASE_URL); String mavenBaseUrl = System.getenv().getOrDefault("CAMEL_SELF_UPDATE_MAVEN_BASE_URL", DEFAULT_MAVEN_BASE_URL); - return new ManifestFetcher(manifestBaseUrl, mavenBaseUrl); + String snapshotBaseUrl = System.getenv().getOrDefault("CAMEL_SELF_UPDATE_SNAPSHOT_BASE_URL", + DEFAULT_SNAPSHOT_BASE_URL); + return new ManifestFetcher(manifestBaseUrl, mavenBaseUrl, snapshotBaseUrl); } /** @@ -123,7 +153,33 @@ public final class ManifestFetcher implements AutoCloseable { return parse(get(url, null)); } + /** + * The snapshot version of main: the highest {@code X.Y.Z-SNAPSHOT} in the snapshot repository (the maintenance + * branches publish nightly builds too, under lower versions). + */ + public String fetchLatestSnapshotVersion() { + String url = snapshotBaseUrl + "/maven-metadata.xml"; + return parseLatestSnapshotVersion(get(url, "no nightly builds at " + url, "snapshot metadata")); + } + + /** + * The newest nightly build of {@code snapshotVersion} ({@code X.Y.Z-SNAPSHOT}), such as + * {@code 4.24.0-20261108.143653-24}. + */ + public String fetchNewestBuild(String snapshotVersion) { + if (!Nightly.isSnapshotVersion(snapshotVersion)) { + throw new IllegalArgumentException("not a snapshot version (expected X.Y.Z-SNAPSHOT): " + snapshotVersion); + } + String url = snapshotBaseUrl + "/" + snapshotVersion + "/maven-metadata.xml"; + return parseNewestBuild(get(url, "no nightly builds of " + snapshotVersion + " at " + url, "snapshot metadata"), + snapshotVersion); + } + private byte[] get(String url, String notFoundMessage) { + return get(url, notFoundMessage, "manifest"); + } + + private byte[] get(String url, String notFoundMessage, String what) { try { HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(CONNECT_TIMEOUT).build(); HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); @@ -132,17 +188,17 @@ public final class ManifestFetcher implements AutoCloseable { } if (response.statusCode() != 200) { throw new SelfUpdateException( - "failed to download manifest from " + url + " (HTTP " + "failed to download " + what + " from " + url + " (HTTP " + response.statusCode() + ")"); } return response.body(); } catch (SelfUpdateException e) { throw e; } catch (IOException e) { - throw new SelfUpdateException("failed to download manifest from " + url, e); + throw new SelfUpdateException("failed to download " + what + " from " + url, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new SelfUpdateException("failed to download manifest from " + url, e); + throw new SelfUpdateException("failed to download " + what + " from " + url, e); } } @@ -210,6 +266,79 @@ public final class ManifestFetcher implements AutoCloseable { return new Manifest(version, values.get("tar_sha256"), values.get("zip_sha256")); } + // Package-visible for unit testing. The highest X.Y.Z-SNAPSHOT of <versions>; other entries are ignored. + static String parseLatestSnapshotVersion(byte[] content) { + String latest = null; + NodeList versions = parseXml(content).getElementsByTagName("version"); + for (int i = 0; i < versions.getLength(); i++) { + String version = versions.item(i).getTextContent().strip(); + if (Nightly.isSnapshotVersion(version) + && (latest == null || VersionHelper.compare(base(version), base(latest)) > 0)) { + latest = version; + } + } + if (latest == null) { + throw new SelfUpdateException("the snapshot repository lists no X.Y.Z-SNAPSHOT version"); + } + return latest; + } + + // Package-visible for unit testing. The <value> of the bin tar.gz <snapshotVersion> (Maven 3 metadata), or else + // the <timestamp>-<buildNumber> of the <snapshot> (Maven 2 metadata, which Nexus 2 serves to a client it does not + // take for Maven 3, such as the JDK HTTP client), as all the files of one deploy share them. It must be a nightly + // build of snapshotVersion: it is used in a directory name and a download URL. + static String parseNewestBuild(byte[] content, String snapshotVersion) { + Document document = parseXml(content); + String build = null; + NodeList entries = document.getElementsByTagName("snapshotVersion"); + for (int i = 0; i < entries.getLength() && build == null; i++) { + Element entry = (Element) entries.item(i); + if ("bin".equals(childText(entry, "classifier")) && "tar.gz".equals(childText(entry, "extension"))) { + build = String.valueOf(childText(entry, "value")); + } + } + if (build == null) { + NodeList snapshots = document.getElementsByTagName("snapshot"); + if (snapshots.getLength() == 0) { + throw new SelfUpdateException("the snapshot metadata of " + snapshotVersion + " has no nightly build"); + } + Element snapshot = (Element) snapshots.item(0); + build = base(snapshotVersion) + "-" + childText(snapshot, "timestamp") + "-" + childText(snapshot, "buildNumber"); + } + if (!Nightly.isBuild(build) || !Nightly.snapshotVersionOf(build).equals(snapshotVersion)) { + throw new SelfUpdateException( + "the snapshot metadata names an invalid nightly build of " + snapshotVersion + ": " + build); + } + return build; + } + + private static String base(String snapshotVersion) { + return snapshotVersion.substring(0, snapshotVersion.length() - "-SNAPSHOT".length()); + } + + private static String childText(Element parent, String name) { + NodeList children = parent.getElementsByTagName(name); + return children.getLength() > 0 ? children.item(0).getTextContent().strip() : null; + } + + // The metadata comes from the network: no DTD, no external entities or schemas. The JDK's own parser, not + // whichever one the class path offers (an old Xerces is on it, which does not know the JAXP 1.5 properties). + private static Document parseXml(byte[] content) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newDefaultInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(new ByteArrayInputStream(content)); + } catch (Exception e) { + throw new SelfUpdateException("the snapshot metadata is not valid: " + e.getMessage(), e); + } + } + private static void validateSha256(String value, String label) { if (!value.matches("[0-9a-f]{64}")) { throw new SelfUpdateException(label + " is not a 64-character lowercase hex value"); diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java new file mode 100644 index 000000000000..296f9d9f1cc9 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.dsl.jbang.launcher.selfupdate; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.dsl.jbang.core.common.InstallDetector; + +/** + * The versions of the nightly builds of the launcher, which Apache publishes to its snapshot repository: a snapshot + * version ({@code 4.24.0-SNAPSHOT}), the newest snapshot of main ({@code SNAPSHOT}), and one nightly build, named as + * the snapshot repository names it ({@code 4.24.0-20261108.143653-24}: the timestamp and build number of the deploy). + * <p> + * A nightly build is not an Apache release. The web installer installs each nightly build in its own directory, named + * after it, so a command that still runs from the previous build keeps its files. + */ +public final class Nightly { + + /** The selector for the newest snapshot of main. */ + public static final String LATEST = "SNAPSHOT"; + + private static final Pattern SNAPSHOT_VERSION = Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+)-SNAPSHOT"); + private static final Pattern BUILD = Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+)-([0-9]{8}\\.[0-9]{6})-([0-9]{1,9})"); + + private Nightly() { + } + + /** Whether {@code value} is a snapshot version, {@code X.Y.Z-SNAPSHOT}. */ + public static boolean isSnapshotVersion(String value) { + return value != null && SNAPSHOT_VERSION.matcher(value).matches(); + } + + /** Whether {@code value} is the selector of the newest snapshot of main, {@code SNAPSHOT} (case-insensitive). */ + public static boolean isLatest(String value) { + return LATEST.equalsIgnoreCase(value); + } + + /** Whether {@code value} is one nightly build, {@code X.Y.Z-YYYYMMDD.HHMMSS-N}. */ + public static boolean isBuild(String value) { + return value != null && BUILD.matcher(value).matches(); + } + + /** + * Whether {@code value} asks for nightly builds: {@code SNAPSHOT}, {@code X.Y.Z-SNAPSHOT} or one nightly build. + */ + public static boolean isNightly(String value) { + return isLatest(value) || isSnapshotVersion(value) || isBuild(value); + } + + /** Normalizes a nightly selector: {@code snapshot} becomes {@code SNAPSHOT}, the others are kept. */ + public static String normalize(String value) { + return isLatest(value) ? LATEST : value; + } + + /** The snapshot version of a nightly build: {@code 4.24.0-SNAPSHOT} for {@code 4.24.0-20261108.143653-24}. */ + public static String snapshotVersionOf(String build) { + Matcher m = BUILD.matcher(build); + if (!m.matches()) { + throw new IllegalArgumentException("not a nightly build: " + build); + } + return m.group(1) + "-SNAPSHOT"; + } + + /** + * Compares two nightly builds of the same snapshot version by their deploy (timestamp, then build number); a + * positive value when {@code a} is newer. + */ + public static int compare(String a, String b) { + Matcher ma = BUILD.matcher(a); + Matcher mb = BUILD.matcher(b); + if (!ma.matches() || !mb.matches()) { + throw new IllegalArgumentException("not nightly builds: " + a + ", " + b); + } + int c = ma.group(2).compareTo(mb.group(2)); + return c != 0 ? c : Integer.compare(Integer.parseInt(ma.group(3)), Integer.parseInt(mb.group(3))); + } + + /** + * The nightly build the launcher runs from: the name of its directory under the web installer's {@code versions} + * directory, when that is a nightly build. + */ + public static Optional<String> runningBuild(Path launcherJar, Path versionsRoot) { + return InstallDetector.nightlyBuildOf(launcherJar, versionsRoot); + } + + /** + * Describes a nightly selector for messages: {@code the nightly builds of main}, {@code ... of 4.24.0-SNAPSHOT}. + */ + public static String describe(String selector) { + if (isLatest(selector)) { + return "the nightly builds of main"; + } + if (isSnapshotVersion(selector)) { + return "the nightly builds of " + selector; + } + return "nightly build " + selector; + } + + /** The notice printed before a nightly build is installed. */ + public static String notice(String build) { + return "Note: " + build + " is a nightly development build of Apache Camel, for testing only. It is not an Apache" + + " release, and is verified only against the SHA-1 published next to it in the snapshot repository."; + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java index 77a3a9a5eb08..00faf995ad76 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java @@ -21,8 +21,10 @@ import java.util.regex.Pattern; /** * The content of the web installer's {@code pinned-version} file, as written by {@code install.sh}/{@code install.ps1} * for an explicit {@code --version}/{@code -Version}: an exact release ({@code X.Y.Z}), a release line ({@code X.Y}), - * or the newest LTS release ({@code LTS}). Anything else is {@link Kind#INVALID} and treated like an exact pin, so an - * unexpected pin file never lets {@code camel self-update} move the install. + * the newest LTS release ({@code LTS}), the nightly builds of a snapshot version ({@code X.Y.Z-SNAPSHOT}) or of main + * ({@code SNAPSHOT}), or one nightly build ({@code X.Y.Z-YYYYMMDD.HHMMSS-N}, see {@link Nightly}). Anything else is + * {@link Kind#INVALID} and treated like an exact pin, so an unexpected pin file never lets {@code camel self-update} + * move the install. */ public record Pin(Kind kind, String value) { @@ -33,6 +35,10 @@ public record Pin(Kind kind, String value) { EXACT, LINE, LTS, + /** The nightly builds of a snapshot version ({@code X.Y.Z-SNAPSHOT}) or of main ({@code SNAPSHOT}). */ + SNAPSHOT, + /** One nightly build, which never moves, as an exact release. */ + NIGHTLY, INVALID } @@ -47,6 +53,12 @@ public record Pin(Kind kind, String value) { if (LINE.matcher(value).matches()) { return new Pin(Kind.LINE, value); } + if (Nightly.isLatest(value) || Nightly.isSnapshotVersion(value)) { + return new Pin(Kind.SNAPSHOT, Nightly.normalize(value)); + } + if (Nightly.isBuild(value)) { + return new Pin(Kind.NIGHTLY, value); + } return new Pin(Kind.INVALID, value); } @@ -58,6 +70,14 @@ public record Pin(Kind kind, String value) { return kind == Kind.LINE || kind == Kind.LTS; } + /** + * Whether this pin follows the nightly builds of a snapshot version or of main, which {@code camel self-update} + * resolves from the snapshot repository rather than from a published alias. + */ + public boolean followsNightlies() { + return kind == Kind.SNAPSHOT; + } + /** * The alias manifest name ({@code lts} or {@code X.Y}) for a {@link #tracksAlias() tracking} pin. */ @@ -70,14 +90,17 @@ public record Pin(Kind kind, String value) { } /** - * Whether {@code version} (a valid X.Y.Z) satisfies this pin: any release for LTS, a release of the line for X.Y, - * that exact release otherwise. + * Whether {@code version} (a valid X.Y.Z, or a nightly build for a snapshot pin) satisfies this pin: any release + * for LTS, a release of the line for X.Y, a nightly build of the snapshot version (or any, for {@code SNAPSHOT}) + * for a snapshot pin, that exact release or nightly build otherwise. */ public boolean accepts(String version) { return switch (kind) { case LTS -> EXACT.matcher(version).matches(); case LINE -> EXACT.matcher(version).matches() && version.startsWith(value + "."); - case EXACT -> value.equals(version); + case EXACT, NIGHTLY -> value.equals(version); + case SNAPSHOT -> Nightly.isBuild(version) + && (Nightly.LATEST.equals(value) || Nightly.snapshotVersionOf(version).equals(value)); default -> false; }; } @@ -89,6 +112,7 @@ public record Pin(Kind kind, String value) { return switch (kind) { case LTS -> "LTS"; case LINE -> "the " + value + " release line"; + case SNAPSHOT -> Nightly.describe(value); default -> "version " + value; }; } diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java index a0b7b3cc3ba2..7a59a4c9345b 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.launcher.selfupdate; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -42,12 +43,18 @@ import picocli.CommandLine; "%nExamples:", " camel self-update", " camel self-update --version 4.23.0", + " camel self-update --version SNAPSHOT", " camel self-update --check", "%nAn install pinned with the web installer's --version X.Y.Z never moves; one pinned with", - "--version X.Y or --version LTS moves to the newest release of that line, or the newest LTS." }) + "--version X.Y or --version LTS moves to the newest release of that line, or the newest LTS.", + "%n--version SNAPSHOT (main) or X.Y.Z-SNAPSHOT installs the newest nightly build from the Apache", + "snapshot repository, and pins it, so camel self-update follows the nightly builds. Nightly", + "builds are development builds for testing, not Apache releases." }) public class SelfUpdateCommand extends CamelCommand { - @CommandLine.Option(names = { "--version" }, description = "Install a specific version instead of the latest") + @CommandLine.Option(names = { "--version" }, + description = "Install a specific version instead of the latest: X.Y.Z, or the nightly builds of" + + " SNAPSHOT (main) or X.Y.Z-SNAPSHOT") String version; @CommandLine.Option(names = { "--check" }, description = "Only report whether a newer version is available") @@ -86,12 +93,12 @@ public class SelfUpdateCommand extends CamelCommand { // only within its selector, so an explicit --version, which could leave it, is refused as well. Optional<String> pinned = InstallDetector.pinnedVersion(); Pin pin = pinned.map(Pin::parse).orElse(null); - if (pin != null && !pin.tracksAlias()) { + if (pin != null && !pin.tracksAlias() && !pin.followsNightlies()) { printer().printErr("this install is pinned to version " + pinned.get() + " - remove " + InstallDetector.pinnedVersionFile() + " to resume tracking new releases"); return 1; } - if (pin != null && version != null) { + if (pin != null && pin.tracksAlias() && version != null) { // Removing the pin file would make the install track the latest release, which is not what asking for // one version means, so point at the installer, which replaces the selector pin with an exact one. printer().printErr(selectorPinRefusal(pin, version)); @@ -99,6 +106,18 @@ public class SelfUpdateCommand extends CamelCommand { } ManifestFetcher fetcher = fetcherOverride != null ? fetcherOverride : ManifestFetcher.fromEnvironment(); + if (version != null && Nightly.isNightly(version)) { + return updateToNightly(fetcher, info, Nightly.normalize(version), true); + } + if (version == null && pin != null && pin.followsNightlies()) { + return updateToNightly(fetcher, info, pin.value(), false); + } + + // From here on a release. An explicit --version leaves a nightly pin (the pin is removed once installed). + boolean leavesNightlies = pin != null && pin.followsNightlies(); + if (leavesNightlies) { + pin = null; + } ManifestFetcher.Manifest manifest; try { if (pin != null) { @@ -147,7 +166,75 @@ public class SelfUpdateCommand extends CamelCommand { // script's own manifest fetch below, and the script would silently install a version this command never // actually decided on. For an X.Y or LTS pin this is the version the alias resolved to; the delegated run // (CAMEL_INSTALL_SELF_UPDATE=true) leaves the selector in the pin file untouched. - return runInstaller(manifest.version()); + int exit = runInstaller(manifest.version()); + if (exit == 0 && leavesNightlies) { + return writePin(null); + } + return exit; + } + + // Installs the newest nightly build of the selector (SNAPSHOT or X.Y.Z-SNAPSHOT), or the nightly build it names. + // An explicit --version pins the selector, so a later camel self-update follows the nightly builds; following the + // pin (explicit=false) only installs a newer build. + private Integer updateToNightly( + ManifestFetcher fetcher, InstallDetector.InstallInfo info, String selector, boolean explicit) { + String build; + try { + if (Nightly.isBuild(selector)) { + build = selector; + } else { + String snapshotVersion = Nightly.isLatest(selector) ? fetcher.fetchLatestSnapshotVersion() : selector; + build = fetcher.fetchNewestBuild(snapshotVersion); + } + } catch (SelfUpdateException e) { + printer().printErr(e.getMessage()); + return 1; + } + + Optional<String> running = Nightly.runningBuild(info.location(), InstallDetector.webInstallerVersionsRoot()); + boolean same = running.isPresent() && running.get().equals(build); + boolean newer = running.isEmpty() + || !Nightly.snapshotVersionOf(running.get()).equals(Nightly.snapshotVersionOf(build)) + || Nightly.compare(build, running.get()) > 0; + String current = running.orElseGet(() -> new DefaultCamelCatalog().getCatalogVersion()); + + if (checkOnly) { + printer().println(same || !newer + ? "Camel CLI is already on the newest nightly build for " + Nightly.describe(selector) + " (" + current + + ")" + : "A new nightly build is available (" + current + " -> " + build + ")"); + return 0; + } + if (same || (!newer && !explicit)) { + printer().println( + "Camel CLI is already on the newest nightly build for " + Nightly.describe(selector) + " (" + current + + ")"); + return explicit && !Nightly.isBuild(selector) ? writePin(selector) : 0; + } + + printer().println(Nightly.notice(build)); + int exit = runInstaller(build); + if (exit == 0 && explicit && !Nightly.isBuild(selector)) { + return writePin(selector); + } + return exit; + } + + // Writes (or, for null, removes) the pin of the web installer, as install.sh does for an explicit --version. + private int writePin(String selector) { + Path file = InstallDetector.pinnedVersionFile(); + try { + if (selector == null) { + Files.deleteIfExists(file); + } else { + Files.createDirectories(file.getParent()); + Files.writeString(file, selector + "\n", StandardCharsets.UTF_8); + } + return 0; + } catch (IOException e) { + printer().printErr("installed, but failed to update the pin in " + file, e); + return 1; + } } private Integer runInstaller(String targetVersion) { diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java index 88b2ae01ef5a..8fd154919ab8 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java @@ -60,7 +60,8 @@ public final class UpdateChecker { } // An exact pin refuses `camel self-update` outright (see SelfUpdateCommand); telling the user to run it // here would be advice they can't actually follow. An X.Y or LTS pin can move within its selector, so - // check the matching alias instead of latest.properties. + // check the matching alias instead of latest.properties. A pin to nightly builds gets no notice: a new + // nightly build lands every day, and camel self-update installs the newest one when asked. Pin pin = InstallDetector.pinnedVersion().map(Pin::parse).orElse(null); if (pin != null && !pin.tracksAlias()) { return; diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java index 497ace4e8297..81d850966785 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java @@ -122,8 +122,8 @@ class SelfUpdateIntegrationTest { private int run(WebsiteInstallerFixture fixture, Path home, Path xdgDataHome, String... args) throws Exception { HttpClient httpClient = trustingHttpClient(fixture); - ManifestFetcher fetcher - = new ManifestFetcher(fixture.baseUrl() + "/camel-cli/releases", fixture.mavenUrl(), httpClient); + ManifestFetcher fetcher = new ManifestFetcher( + fixture.baseUrl() + "/camel-cli/releases", fixture.mavenUrl(), fixture.snapshotUrl(), httpClient); InstallScriptFetcher scriptFetcher = new InstallScriptFetcher(fixture.baseUrl(), httpClient); Map<String, String> installerEnv = fixture.environment(home); SelfUpdateCommand cmd @@ -470,4 +470,117 @@ class SelfUpdateIntegrationTest { assertThat(printer.getOutput()).contains("Homebrew"); } } + + private static final String OLD_NIGHTLY = "9.9.0-20261101.010000-1"; + + @Test + void installsNewestNightlyOfMainAndPinsSnapshot(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, "1.0.0"); + publishRealInstallSh(fixture); + WebsiteInstallTest.publishSnapshotVersions(fixture, "9.9.0-SNAPSHOT", "9.8.1-SNAPSHOT"); + WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", WebsiteInstallTest.NIGHTLY, null); + + int exit = run(fixture, home, xdgDataHome, "--version", "snapshot"); + + assertThat(exit).isZero(); + assertThat(printer.getOutput()).contains("Installed Camel CLI " + WebsiteInstallTest.NIGHTLY); + // once: install.sh leaves the note to camel self-update + assertThat(printer.getOutput().split("is a nightly development build of Apache Camel", -1)).hasSize(2); + assertThat(xdgDataHome.resolve("camel-cli/versions/" + WebsiteInstallTest.NIGHTLY)).isDirectory(); + assertThat(xdgDataHome.resolve("camel-cli/pinned-version")).hasContent("SNAPSHOT"); + } + } + + @Test + void snapshotPinMovesToNewerNightly(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, OLD_NIGHTLY); + Path pinFile = pin(xdgDataHome, "9.9.0-SNAPSHOT"); + publishRealInstallSh(fixture); + WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", WebsiteInstallTest.NIGHTLY, null); + + int exit = run(fixture, home, xdgDataHome); + + assertThat(exit).isZero(); + assertThat(xdgDataHome.resolve("camel-cli/versions/" + WebsiteInstallTest.NIGHTLY)).isDirectory(); + assertThat(pinFile).hasContent("9.9.0-SNAPSHOT"); + } + } + + @Test + void snapshotPinOnNewestNightlySkipsDelegation(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, WebsiteInstallTest.NIGHTLY); + pin(xdgDataHome, "SNAPSHOT"); + // install.sh is deliberately not published: delegating would fail + WebsiteInstallTest.publishSnapshotVersions(fixture, "9.9.0-SNAPSHOT"); + WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", WebsiteInstallTest.NIGHTLY, null); + + int exit = run(fixture, home, xdgDataHome); + + assertThat(exit).isZero(); + assertThat(printer.getOutput()).contains("already on the newest nightly build for the nightly builds of main"); + } + } + + @Test + void checksForNewerNightlyWithoutDelegating(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, OLD_NIGHTLY); + pin(xdgDataHome, "9.9.0-SNAPSHOT"); + WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", WebsiteInstallTest.NIGHTLY, null); + + int exit = run(fixture, home, xdgDataHome, "--check"); + + assertThat(exit).isZero(); + assertThat(printer.getOutput()) + .contains("A new nightly build is available (" + OLD_NIGHTLY + " -> " + WebsiteInstallTest.NIGHTLY + ")"); + assertThat(xdgDataHome.resolve("camel-cli/versions/" + WebsiteInstallTest.NIGHTLY)).doesNotExist(); + } + } + + @Test + void explicitReleaseLeavesTheNightlies(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, WebsiteInstallTest.NIGHTLY); + Path pinFile = pin(xdgDataHome, "SNAPSHOT"); + publishRealInstallSh(fixture); + WebsiteInstallTest.publishVersion(fixture, NEWER_VERSION); + + int exit = run(fixture, home, xdgDataHome, "--version", NEWER_VERSION); + + assertThat(exit).isZero(); + assertThat(xdgDataHome.resolve("camel-cli/versions/" + NEWER_VERSION)).isDirectory(); + // back to following the releases + assertThat(pinFile).doesNotExist(); + } + } + + @Test + void exactPinRefusesNightlies(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectories(temp.resolve("home")); + Path xdgDataHome = home.resolve(".local/share"); + markRunningAsWebInstaller(home, xdgDataHome, "1.0.0"); + pin(xdgDataHome, "1.0.0"); + WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", WebsiteInstallTest.NIGHTLY, null); + + int exit = run(fixture, home, xdgDataHome, "--version", "9.9.0-SNAPSHOT"); + + assertThat(exit).isEqualTo(1); + assertThat(printer.getOutput()).contains("pinned to version 1.0.0"); + assertThat(xdgDataHome.resolve("camel-cli/versions/" + WebsiteInstallTest.NIGHTLY)).doesNotExist(); + } + } } diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java index 2be6dd700b60..8c76a5677b7b 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java @@ -26,10 +26,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Set; @@ -97,7 +99,63 @@ class WebsiteInstallTest { } static Stream<String> malformedSelectors() { - return Stream.of("2", "2.", ".2", "2..1", "1.2.3.4", "1.2.3-SNAPSHOT", "v2.1", "latest", "LTS2", "lt", "2.x"); + return Stream.of("2", "2.", ".2", "2..1", "1.2.3.4", "1.2.3-snapshot", "v2.1", "latest", "LTS2", "lt", "2.x"); + } + + // install.sh only: install.ps1 does not install nightly builds yet. + static Stream<String> malformedNightlySelectors() { + return Stream.of("1.2.3-SNAPSHOT1", "1.2-SNAPSHOT", "SNAPSHOTS", "1.2.3-20261108.143653", "1.2.3-20261108-24", + "1.2.3-20261108.143653-x", "1.2.3-20261108.143653-24/..", "1.2.3-20261108.143653-1234567890"); + } + + static final String NIGHTLY = "9.9.0-20261108.143653-24"; + + /** + * Publishes nightly build {@code build} of {@code snapshotVersion} as the Apache snapshot repository does: the + * archive (which unpacks to camel-launcher-X.Y.Z-SNAPSHOT), its {@code .sha1}, and the version metadata naming it + * as the newest build. {@code sha1} overrides the published checksum when not null. + */ + static void publishNightly( + WebsiteInstallerFixture fixture, String snapshotVersion, String build, String sha1) + throws Exception { + Path tar = fixture.safeTar(snapshotVersion); + String base = "/snapshots/org/apache/camel/camel-launcher/" + snapshotVersion; + fixture.publish(base + "/camel-launcher-" + build + "-bin.tar.gz", Files.readAllBytes(tar)); + String checksum = sha1 != null + ? sha1 : HexFormat.of().formatHex(MessageDigest.getInstance("SHA-1").digest(Files.readAllBytes(tar))); + fixture.publish(base + "/camel-launcher-" + build + "-bin.tar.gz.sha1", checksum.getBytes(StandardCharsets.UTF_8)); + String metadata = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + + "<metadata modelVersion=\"1.1.0\">\n" + + " <version>" + snapshotVersion + "</version>\n" + + " <versioning>\n" + + " <snapshotVersions>\n" + + " <snapshotVersion>\n" + + " <extension>pom</extension>\n" + + " <value>" + build + "</value>\n" + + " </snapshotVersion>\n" + + " <snapshotVersion>\n" + + " <classifier>bin</classifier>\n" + + " <extension>tar.gz</extension>\n" + + " <value>" + build + "</value>\n" + + " </snapshotVersion>\n" + + " </snapshotVersions>\n" + + " </versioning>\n" + + "</metadata>\n"; + fixture.publish(base + "/maven-metadata.xml", metadata.getBytes(StandardCharsets.UTF_8)); + } + + /** Publishes the root metadata of the snapshot repository, listing these snapshot versions. */ + static void publishSnapshotVersions(WebsiteInstallerFixture fixture, String... snapshotVersions) { + StringBuilder metadata = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n <versioning>\n"); + // <latest> names the last deploy, which is not the highest version + metadata.append(" <latest>").append(snapshotVersions[snapshotVersions.length - 1]).append("</latest>\n"); + metadata.append(" <versions>\n"); + for (String version : snapshotVersions) { + metadata.append(" <version>").append(version).append("</version>\n"); + } + metadata.append(" </versions>\n </versioning>\n</metadata>\n"); + fixture.publish("/snapshots/org/apache/camel/camel-launcher/maven-metadata.xml", + metadata.toString().getBytes(StandardCharsets.UTF_8)); } static void publishRelease(WebsiteInstallerFixture fixture, String version, Path tar, Path zip) throws Exception { @@ -504,6 +562,188 @@ class WebsiteInstallTest { } } + private static void assertNightlyInstalled(Path home, String build, String snapshotVersion) throws Exception { + Path shim = home.resolve(".local/bin/camel"); + Path versionDir = home.resolve(".local/share/camel-cli/versions/" + build); + assertTrue(Files.isSymbolicLink(shim), "expected a symlink at " + shim); + assertEquals(versionDir.resolve("bin/camel.sh"), Files.readSymbolicLink(shim)); + String output = WebsiteInstallerFixture.execute( + new ProcessBuilder("/bin/sh", shim.toString(), "version").redirectErrorStream(true)).stdout(); + assertThat(output).contains("Camel " + snapshotVersion); + } + + @Test + void installsNewestNightlyOfMainAndPinsSnapshot(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + // main is the highest snapshot version, not the one deployed last + publishSnapshotVersions(fixture, "9.9.0-SNAPSHOT", "9.8.1-SNAPSHOT"); + publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null); + + WebsiteInstallerFixture.Result r = install(fixture, home, "snapshot"); + + assertEquals(0, r.exit(), r.stderr()); + assertThat(r.stdout()).contains("is a nightly development build of Apache Camel") + .contains("Installed Camel CLI " + NIGHTLY + " (a nightly build of 9.9.0-SNAPSHOT)"); + assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT"); + assertThat(pinnedVersionFile(home)).hasContent("SNAPSHOT"); + } + } + + @Test + void installsNewestNightlyOfSnapshotVersionAndPinsIt(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.8.1-SNAPSHOT", "9.8.1-20261107.010203-7", null); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.8.1-SNAPSHOT"); + + assertEquals(0, r.exit(), r.stderr()); + assertNightlyInstalled(home, "9.8.1-20261107.010203-7", "9.8.1-SNAPSHOT"); + assertThat(pinnedVersionFile(home)).hasContent("9.8.1-SNAPSHOT"); + } + } + + // what camel self-update passes on, after resolving the newest build itself + @Test + void installsOneNightlyBuild(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null); + + WebsiteInstallerFixture.Result r = install(fixture, home, NIGHTLY); + + assertEquals(0, r.exit(), r.stderr()); + assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT"); + assertThat(pinnedVersionFile(home)).hasContent(NIGHTLY); + } + } + + // Nexus 2 serves Maven 2 metadata to a client it does not take for Maven 3 (wget, for one) + @Test + void installsNightlyFromMaven2Metadata(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null); + String metadata = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + + "<metadata>\n" + + " <version>9.9.0-SNAPSHOT</version>\n" + + " <versioning>\n" + + " <snapshot>\n" + + " <timestamp>20261108.143653</timestamp>\n" + + " <buildNumber>24</buildNumber>\n" + + " </snapshot>\n" + + " <lastUpdated>20261108143653</lastUpdated>\n" + + " </versioning>\n" + + "</metadata>\n"; + fixture.publish("/snapshots/org/apache/camel/camel-launcher/9.9.0-SNAPSHOT/maven-metadata.xml", + metadata.getBytes(StandardCharsets.UTF_8)); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.0-SNAPSHOT"); + + assertEquals(0, r.exit(), r.stderr()); + assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT"); + } + } + + @Test + void rejectsNightlyWithWrongSha1(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, "0".repeat(40)); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.0-SNAPSHOT"); + + assertNotEquals(0, r.exit()); + assertThat(r.stderr()).contains("checksum mismatch for downloaded archive"); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + assertFalse(Files.exists(home.resolve(".local/share/camel-cli/versions/" + NIGHTLY))); + assertThat(pinnedVersionFile(home)).doesNotExist(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("org.apache.camel.dsl.jbang.launcher.WebsiteInstallTest#malformedNightlySelectors") + void rejectsMalformedNightlySelector(String selector, @TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + + WebsiteInstallerFixture.Result r = install(fixture, home, selector); + + assertNotEquals(0, r.exit()); + assertThat(r.stderr()).contains("invalid --version value"); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + // The build name from the metadata becomes a directory name and a URL: anything else than a nightly build of + // the requested snapshot version is refused before downloading. + @ParameterizedTest(name = "{0}") + @MethodSource("org.apache.camel.dsl.jbang.launcher.WebsiteInstallTest#malformedNightlySelectors") + void rejectsMalformedBuildInMetadata(String build, @TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", build, null); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.0-SNAPSHOT"); + + assertNotEquals(0, r.exit()); + assertThat(r.stderr()).contains("the snapshot metadata names an invalid nightly build of 9.9.0-SNAPSHOT"); + assertFalse(Files.exists(home.resolve(".local/bin/camel"))); + } + } + + @Test + void rejectsBuildOfAnotherSnapshotVersionInMetadata(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", "9.8.1-20261107.010203-7", null); + + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.0-SNAPSHOT"); + + assertNotEquals(0, r.exit()); + assertThat(r.stderr()).contains("invalid nightly build of 9.9.0-SNAPSHOT: 9.8.1-20261107.010203-7"); + } + } + + @Test + void keepsTheNewestThreeNightliesAndEveryRelease(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishVersion(fixture, "1.2.3"); + assertEquals(0, install(fixture, home, "1.2.3").exit()); + List<String> builds = List.of("9.9.0-20261101.010000-1", "9.9.0-20261102.010000-2", + "9.9.0-20261103.010000-3", "9.9.0-20261104.010000-4", "9.9.0-20261105.010000-5"); + for (String build : builds) { + publishNightly(fixture, "9.9.0-SNAPSHOT", build, null); + WebsiteInstallerFixture.Result r = install(fixture, home, "9.9.0-SNAPSHOT"); + assertEquals(0, r.exit(), r.stderr()); + } + + try (Stream<Path> listing = Files.list(home.resolve(".local/share/camel-cli/versions"))) { + assertThat(listing.map(d -> d.getFileName().toString())).containsExactlyInAnyOrder( + "1.2.3", "9.9.0-20261103.010000-3", "9.9.0-20261104.010000-4", "9.9.0-20261105.010000-5"); + } + assertNightlyInstalled(home, "9.9.0-20261105.010000-5", "9.9.0-SNAPSHOT"); + } + } + + @Test + void releaseAfterNightlyReplacesTheSnapshotPin(@TempDir Path temp) throws Exception { + try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { + Path home = Files.createDirectory(temp.resolve("home")); + publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null); + publishLatest(fixture, "1.2.3"); + assertEquals(0, install(fixture, home, "9.9.0-SNAPSHOT").exit()); + + WebsiteInstallerFixture.Result r = install(fixture, home, null); + + assertEquals(0, r.exit(), r.stderr()); + assertVersionInstalled(home, "1.2.3"); + assertThat(pinnedVersionFile(home)).doesNotExist(); + } + } + @Test void failedSelectorInstallPreservesExistingInstallAndPin(@TempDir Path temp) throws Exception { try (WebsiteInstallerFixture fixture = WebsiteInstallerFixture.start(temp.resolve("fixture"))) { diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java index eb86670ea11a..cadfa3f53af8 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java @@ -209,6 +209,10 @@ final class WebsiteInstallerFixture implements AutoCloseable { return baseUrl() + "/maven2/org/apache/camel/camel-launcher"; } + String snapshotUrl() { + return baseUrl() + "/snapshots/org/apache/camel/camel-launcher"; + } + Path caCertificate() { return caCertPem; } @@ -217,6 +221,7 @@ final class WebsiteInstallerFixture implements AutoCloseable { Map<String, String> env = new HashMap<>(); env.put("CAMEL_INSTALL_MANIFEST_BASE_URL", baseUrl() + "/camel-cli/releases"); env.put("CAMEL_INSTALL_MAVEN_BASE_URL", mavenUrl()); + env.put("CAMEL_INSTALL_SNAPSHOT_BASE_URL", snapshotUrl()); env.put("CAMEL_INSTALL_CA_CERT", caCertPem.toString()); env.put("HOME", home.toString()); env.put("USERPROFILE", home.toString()); diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java index 7455d08c9a91..93fe6a6e4a6c 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java @@ -197,4 +197,157 @@ class ManifestFetcherTest { assertThatThrownBy(() -> fetcher.fetchAlias(name)).isInstanceOf(IllegalArgumentException.class); } + + // As the Apache snapshot repository publishes them (trimmed). + static final String ROOT_METADATA = """ + <?xml version="1.0" encoding="UTF-8"?> + <metadata> + <groupId>org.apache.camel</groupId> + <artifactId>camel-launcher</artifactId> + <versioning> + <latest>4.22.3-SNAPSHOT</latest> + <release></release> + <versions> + <version>4.14.10-SNAPSHOT</version> + <version>4.24.0-SNAPSHOT</version> + <version>4.22.3-SNAPSHOT</version> + <version>4.9.0</version> + </versions> + </versioning> + </metadata> + """; + + static String versionMetadata(String snapshotVersion, String build) { + return """ + <?xml version="1.0" encoding="UTF-8"?> + <metadata modelVersion="1.1.0"> + <groupId>org.apache.camel</groupId> + <artifactId>camel-launcher</artifactId> + <version>%1$s</version> + <versioning> + <snapshot> + <timestamp>20261108.143653</timestamp> + <buildNumber>24</buildNumber> + </snapshot> + <snapshotVersions> + <snapshotVersion> + <extension>pom</extension> + <value>%2$s</value> + </snapshotVersion> + <snapshotVersion> + <classifier>bin</classifier> + <extension>zip</extension> + <value>%2$s</value> + </snapshotVersion> + <snapshotVersion> + <classifier>bin</classifier> + <extension>tar.gz</extension> + <value>%2$s</value> + </snapshotVersion> + </snapshotVersions> + </versioning> + </metadata> + """.formatted(snapshotVersion, build); + } + + @Test + void latestSnapshotVersionIsTheHighestNotTheLastDeployed() { + // <latest> names whatever deployed last, which can be a maintenance branch + assertThat(ManifestFetcher.parseLatestSnapshotVersion(ROOT_METADATA.getBytes(StandardCharsets.UTF_8))) + .isEqualTo("4.24.0-SNAPSHOT"); + } + + @Test + void latestSnapshotVersionRequiresOne() { + String metadata = "<metadata><versioning><versions><version>4.9.0</version></versions></versioning></metadata>"; + + assertThatThrownBy(() -> ManifestFetcher.parseLatestSnapshotVersion(metadata.getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(SelfUpdateException.class) + .hasMessageContaining("no X.Y.Z-SNAPSHOT version"); + } + + @Test + void newestBuildIsTheValueOfTheLauncherArchive() { + byte[] metadata = versionMetadata("4.24.0-SNAPSHOT", "4.24.0-20261108.143653-24").getBytes(StandardCharsets.UTF_8); + + assertThat(ManifestFetcher.parseNewestBuild(metadata, "4.24.0-SNAPSHOT")).isEqualTo("4.24.0-20261108.143653-24"); + } + + // Nexus 2 serves this Maven 2 metadata to the JDK HTTP client: no <snapshotVersions> + @Test + void newestBuildFromMaven2Metadata() { + String metadata = """ + <?xml version="1.0" encoding="UTF-8"?> + <metadata> + <groupId>org.apache.camel</groupId> + <artifactId>camel-launcher</artifactId> + <version>4.24.0-SNAPSHOT</version> + <versioning> + <snapshot> + <timestamp>20261108.143653</timestamp> + <buildNumber>24</buildNumber> + </snapshot> + <lastUpdated>20261108143653</lastUpdated> + </versioning> + </metadata> + """; + + assertThat(ManifestFetcher.parseNewestBuild(metadata.getBytes(StandardCharsets.UTF_8), "4.24.0-SNAPSHOT")) + .isEqualTo("4.24.0-20261108.143653-24"); + } + + @ParameterizedTest + @ValueSource(strings = { "20261108.143653</timestamp><x>", "2026/../../x", "" }) + void rejectsAnInvalidMaven2Snapshot(String timestamp) { + String metadata = "<metadata><versioning><snapshot><timestamp>" + timestamp + + "</timestamp><buildNumber>24</buildNumber></snapshot></versioning></metadata>"; + + assertThatThrownBy(() -> ManifestFetcher.parseNewestBuild(metadata.getBytes(StandardCharsets.UTF_8), + "4.24.0-SNAPSHOT")) + .isInstanceOf(SelfUpdateException.class); + } + + @ParameterizedTest + @ValueSource(strings = { "4.24.0-20261108.143653-24/../../x", "4.22.3-20261108.143653-24", "4.24.0-SNAPSHOT", "" }) + void rejectsAnInvalidOrForeignBuild(String build) { + byte[] metadata = versionMetadata("4.24.0-SNAPSHOT", build).getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> ManifestFetcher.parseNewestBuild(metadata, "4.24.0-SNAPSHOT")) + .isInstanceOf(SelfUpdateException.class) + .hasMessageContaining("invalid nightly build of 4.24.0-SNAPSHOT"); + } + + @Test + void rejectsMetadataWithADoctype() { + String metadata = "<?xml version=\"1.0\"?><!DOCTYPE metadata [<!ENTITY x SYSTEM \"file:///etc/passwd\">]>" + + "<metadata><versioning><versions><version>&x;</version></versions></versioning></metadata>"; + + assertThatThrownBy(() -> ManifestFetcher.parseLatestSnapshotVersion(metadata.getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(SelfUpdateException.class) + .hasMessageContaining("the snapshot metadata is not valid"); + } + + @Test + void fetchesTheNewestBuildOverHttp() throws Exception { + HttpServer server = serve(Map.of( + "/snapshots/maven-metadata.xml", ROOT_METADATA, + "/snapshots/4.24.0-SNAPSHOT/maven-metadata.xml", + versionMetadata("4.24.0-SNAPSHOT", "4.24.0-20261108.143653-24"))); + try { + String base = "http://127.0.0.1:" + server.getAddress().getPort(); + ManifestFetcher fetcher = new ManifestFetcher(base + "/releases", base + "/maven2", base + "/snapshots"); + + String snapshotVersion = fetcher.fetchLatestSnapshotVersion(); + + assertThat(snapshotVersion).isEqualTo("4.24.0-SNAPSHOT"); + assertThat(fetcher.fetchNewestBuild(snapshotVersion)).isEqualTo("4.24.0-20261108.143653-24"); + assertThatThrownBy(() -> fetcher.fetchNewestBuild("4.22.3-SNAPSHOT")) + .isInstanceOf(SelfUpdateException.class) + .hasMessageContaining("no nightly builds of 4.22.3-SNAPSHOT at"); + assertThatThrownBy(() -> fetcher.fetchNewestBuild("../x")) + .isInstanceOf(IllegalArgumentException.class); + } finally { + server.stop(0); + } + } } diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java new file mode 100644 index 000000000000..7858c1929011 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.camel.dsl.jbang.launcher.selfupdate; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NightlyTest { + + @Test + void classifiesNightlySelectors() { + assertThat(Nightly.isLatest("SNAPSHOT")).isTrue(); + assertThat(Nightly.isLatest("snapshot")).isTrue(); + assertThat(Nightly.isSnapshotVersion("4.24.0-SNAPSHOT")).isTrue(); + assertThat(Nightly.isBuild("4.24.0-20261108.143653-24")).isTrue(); + assertThat(Nightly.isNightly("4.24.0")).isFalse(); + assertThat(Nightly.isNightly("LTS")).isFalse(); + assertThat(Nightly.normalize("snapshot")).isEqualTo("SNAPSHOT"); + assertThat(Nightly.normalize("4.24.0-SNAPSHOT")).isEqualTo("4.24.0-SNAPSHOT"); + } + + @ParameterizedTest + @ValueSource(strings = { + "4.24.0-20261108.143653", "4.24.0-20261108-24", "4.24.0-2026110.143653-24", "4.24.0-20261108.143653-", + "4.24.0-20261108.143653-24-1", "4.24-20261108.143653-24", "4.24.0-20261108.143653-24/..", + "../4.24.0-20261108.143653-24", "4.24.0-20261108.143653-1234567890" }) + void rejectsMalformedBuilds(String value) { + assertThat(Nightly.isBuild(value)).isFalse(); + assertThat(Nightly.isNightly(value)).isFalse(); + } + + @Test + void snapshotVersionOfBuild() { + assertThat(Nightly.snapshotVersionOf("4.22.3-20261107.143456-15")).isEqualTo("4.22.3-SNAPSHOT"); + assertThatThrownBy(() -> Nightly.snapshotVersionOf("4.22.3")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void comparesBuildsByTimestampThenBuildNumber() { + assertThat(Nightly.compare("4.24.0-20261109.010000-25", "4.24.0-20261108.143653-24")).isPositive(); + assertThat(Nightly.compare("4.24.0-20261108.143653-24", "4.24.0-20261108.143653-24")).isZero(); + // the build number is compared as a number, not as text + assertThat(Nightly.compare("4.24.0-20261108.143653-10", "4.24.0-20261108.143653-9")).isPositive(); + } + + @Test + void runningBuildIsTheVersionDirectoryOfTheLauncher() { + Path versions = Path.of("/home/user/.local/share/camel-cli/versions"); + + assertThat(Nightly.runningBuild( + versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar"), versions)) + .contains("4.24.0-20261108.143653-24"); + assertThat(Nightly.runningBuild(versions.resolve("4.23.0/lib/camel-launcher-4.23.0.jar"), versions)).isEmpty(); + assertThat(Nightly.runningBuild(Path.of("/opt/camel/lib/camel-launcher-4.24.0-SNAPSHOT.jar"), versions)).isEmpty(); + assertThat(Nightly.runningBuild(null, versions)).isEmpty(); + } +} diff --git a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java index 2eb3533cb3f4..c5d484832ede 100644 --- a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java @@ -68,7 +68,10 @@ class PinTest { } @ParameterizedTest - @ValueSource(strings = { "", "4", "4.", "4.22.", "4..22", "4.22.2.1", "4.22.2-SNAPSHOT", "latest", "LTS2", "v4.22" }) + @ValueSource(strings = { + "", "4", "4.", "4.22.", "4..22", "4.22.2.1", "4.22.2-snapshot", "4.22.2-SNAPSHOT1", "SNAPSHOTS", + "4.22.2-20261108.143653", "4.22.2-20261108.1436-24", "4.22.2-20261108.143653-24/..", "latest", "LTS2", + "v4.22" }) void treatsAnythingElseAsInvalidAndNeverMoves(String raw) { Pin pin = Pin.parse(raw); @@ -76,4 +79,44 @@ class PinTest { assertThat(pin.tracksAlias()).isFalse(); assertThat(pin.accepts("4.22.2")).isFalse(); } + + @ParameterizedTest + @ValueSource(strings = { "SNAPSHOT", "snapshot", " Snapshot\r\n" }) + void parsesNightliesOfMainPin(String raw) { + Pin pin = Pin.parse(raw); + + assertThat(pin.kind()).isEqualTo(Pin.Kind.SNAPSHOT); + assertThat(pin.value()).isEqualTo("SNAPSHOT"); + assertThat(pin.followsNightlies()).isTrue(); + assertThat(pin.tracksAlias()).isFalse(); + assertThat(pin.accepts("4.24.0-20261108.143653-24")).isTrue(); + assertThat(pin.accepts("4.22.3-20261107.143456-15")).isTrue(); + assertThat(pin.accepts("4.24.0")).isFalse(); + assertThat(pin.describe()).isEqualTo("the nightly builds of main"); + assertThatThrownBy(pin::aliasName).isInstanceOf(IllegalStateException.class); + } + + @Test + void parsesNightliesOfSnapshotVersionPin() { + Pin pin = Pin.parse("4.22.3-SNAPSHOT\n"); + + assertThat(pin.kind()).isEqualTo(Pin.Kind.SNAPSHOT); + assertThat(pin.followsNightlies()).isTrue(); + assertThat(pin.accepts("4.22.3-20261107.143456-15")).isTrue(); + assertThat(pin.accepts("4.24.0-20261108.143653-24")).isFalse(); + assertThat(pin.accepts("4.22.3")).isFalse(); + assertThat(pin.describe()).isEqualTo("the nightly builds of 4.22.3-SNAPSHOT"); + } + + @Test + void parsesOneNightlyBuildPinWhichNeverMoves() { + Pin pin = Pin.parse("4.24.0-20261108.143653-24"); + + assertThat(pin.kind()).isEqualTo(Pin.Kind.NIGHTLY); + assertThat(pin.followsNightlies()).isFalse(); + assertThat(pin.tracksAlias()).isFalse(); + assertThat(pin.accepts("4.24.0-20261108.143653-24")).isTrue(); + assertThat(pin.accepts("4.24.0-20261109.143653-25")).isFalse(); + assertThat(pin.describe()).isEqualTo("version 4.24.0-20261108.143653-24"); + } }
