diff --git a/.github/scripts/sonatype-status.sh b/.github/scripts/sonatype-status.sh new file mode 100755 index 0000000..2389917 --- /dev/null +++ b/.github/scripts/sonatype-status.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------- +# sonatype-status.sh – Check Sonatype Central Portal deployment status +# and optionally clean up the local release directory. +# +# For release deployments: queries the Sonatype status API using the +# deployment ID stored by sonatype-upload.sh. +# +# For snapshot deployments: verifies that ALL jar files from the +# snapshot deployment folder are accessible via the Sonatype snapshot +# repository URL. +# +# Usage: +# SONATYPE_BEARER= ./.github/scripts/sonatype-status.sh [options] +# +# Options: +# --snapshot Check snapshot deployment +# --status-url Status API URL (release only) +# --snapshot-url Snapshot repository URL +# --clean Remove release-dir after successful check +# +# Environment: +# SONATYPE_BEARER – Bearer token for authentication (required) +# +# ------------------------------------------------------------------------- +set -euo pipefail + +# ---- defaults ----------------------------------------------------------- +STATUS_URL="https://central.sonatype.com/api/v1/publisher/status" +SNAPSHOT_URL="https://central.sonatype.com/repository/maven-snapshots/" +SNAPSHOT=false +CLEAN=false +RELEASE_DIR="" +# ------------------------------------------------------------------------- + +usage() { + cat <<-EOF + Usage: $(basename "$0") [options] + + Check Sonatype deployment status and optionally clean up. + + Options: + --snapshot Check snapshot deployment + --status-url Status API URL (default: Sonatype Central Portal) + --snapshot-url Snapshot repository URL + --clean Remove release-dir after successful verification + -h, --help Show this help message + + Environment: + SONATYPE_BEARER Bearer token for authentication (required) + EOF + exit "${1:-0}" +} + +# ---- parse arguments ---------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --snapshot) SNAPSHOT=true; shift ;; + --status-url) STATUS_URL="$2"; shift 2 ;; + --snapshot-url) SNAPSHOT_URL="$2"; shift 2 ;; + --clean) CLEAN=true; shift ;; + -h|--help) usage 0 ;; + -*) echo "Unknown option: $1" >&2; usage 1 ;; + *) RELEASE_DIR="$1"; shift ;; + esac +done + +if [[ -z "${RELEASE_DIR}" ]]; then + echo "Error: release directory argument is required" >&2 + usage 1 +fi + +: "${SONATYPE_BEARER:?Error: SONATYPE_BEARER environment variable is not set}" + +# ---- snapshot status check ---------------------------------------------- +if [[ "${SNAPSHOT}" == "true" ]]; then + echo "Checking snapshot deployment status ..." + + SNAPSHOT_BASE="${SNAPSHOT_URL%/}/" + + # Find all jar files in the snapshot deployment folder + JARS=$(find "${RELEASE_DIR}" -name '*.jar' -type f) + + if [[ -z "${JARS}" ]]; then + echo "Error: No jar files found in ${RELEASE_DIR} to verify" >&2 + exit 1 + fi + + TOTAL=0 + AVAILABLE=0 + MISSING=0 + + while IFS= read -r jar_file; do + # Convert to relative path + REL_PATH="${jar_file#"${RELEASE_DIR}"}" + REL_PATH="${REL_PATH#/}" + + CHECK_URL="${SNAPSHOT_BASE}${REL_PATH}" + ((TOTAL++)) || true + + HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${SONATYPE_BEARER}" \ + "${CHECK_URL}") || true + + if [[ "${HTTP_CODE}" -eq 200 ]]; then + echo " OK ${REL_PATH}" + ((AVAILABLE++)) || true + else + echo " MISS ${REL_PATH} (HTTP ${HTTP_CODE})" + ((MISSING++)) || true + fi + done <<< "${JARS}" + + echo "Snapshot verification: ${AVAILABLE}/${TOTAL} artifacts available, ${MISSING} missing." + + if [[ "${MISSING}" -gt 0 ]]; then + echo "Error: Not all snapshot artifacts are available yet." >&2 + exit 1 + fi + + echo "All snapshot artifacts are available." + if [[ "${CLEAN}" == "true" ]]; then + echo "Cleaning up release directory: ${RELEASE_DIR}" + rm -rf "${RELEASE_DIR}" + echo "Release directory removed." + fi + exit 0 +fi + +# ---- release status check ----------------------------------------------- +DEPLOYMENTID_FILE="${RELEASE_DIR%/}_DEPLOYMENTID.txt" + +if [[ ! -f "${DEPLOYMENTID_FILE}" ]]; then + echo "Error: Deployment ID file not found: ${DEPLOYMENTID_FILE}" >&2 + echo "Run sonatype-upload.sh first to create a deployment." >&2 + exit 1 +fi + +DEPLOYMENT_ID=$(cat "${DEPLOYMENTID_FILE}") + +if [[ -z "${DEPLOYMENT_ID}" ]]; then + echo "Error: Deployment ID file is empty: ${DEPLOYMENTID_FILE}" >&2 + exit 1 +fi + +echo "Checking release deployment status ..." +echo " Deployment ID: ${DEPLOYMENT_ID}" +echo " Status URL: ${STATUS_URL}" + +STATUS_RESPONSE=$(curl -sS \ + -H "Authorization: Bearer ${SONATYPE_BEARER}" \ + "${STATUS_URL}?id=${DEPLOYMENT_ID}" 2>&1) || true + +# Extract deploymentState from JSON response (portable, no grep -P) +STATE=$(echo "${STATUS_RESPONSE}" | sed -n 's/.*"deploymentState"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +echo "Deployment state: ${STATE:-UNKNOWN}" + +case "${STATE}" in + PUBLISHED) + echo "Deployment successfully published." + if [[ "${CLEAN}" == "true" ]]; then + echo "Cleaning up release directory: ${RELEASE_DIR}" + rm -rf "${RELEASE_DIR}" + rm -f "${DEPLOYMENTID_FILE}" + echo "Release directory and deployment ID file removed." + fi + exit 0 + ;; + VALIDATED) + echo "Deployment validated (awaiting manual publishing)." + exit 0 + ;; + PENDING|VALIDATING|PUBLISHING) + echo "Deployment is still in progress (${STATE})." + exit 2 + ;; + FAILED) + echo "Error: Deployment failed." >&2 + echo "${STATUS_RESPONSE}" >&2 + exit 1 + ;; + *) + echo "Unknown deployment state: ${STATE:-UNKNOWN}" >&2 + echo "${STATUS_RESPONSE}" >&2 + exit 1 + ;; +esac diff --git a/.github/scripts/sonatype-upload.sh b/.github/scripts/sonatype-upload.sh index 7c955e3..f621153 100755 --- a/.github/scripts/sonatype-upload.sh +++ b/.github/scripts/sonatype-upload.sh @@ -7,7 +7,9 @@ # combination of Gradle / Maven / bnd builds have populated a shared # release directory. # -# Uploads release bundles via /api/v1/publisher/upload. +# Supports both release and snapshot deployments: +# - Release: uploads via /api/v1/publisher/upload +# - Snapshot: deploys via Maven-style PUT to /repository/maven-snapshots/ # # Note: uses `jar cMf` (from JDK) instead of `zip` for git bash compatibility. # @@ -15,63 +17,27 @@ # SONATYPE_BEARER= ./.github/scripts/sonatype-upload.sh [options] # # Options: +# --snapshot Deploy as snapshot (to maven-snapshots repo) # --publishing-type (default: USER_MANAGED, release only) # --name (default: auto-generated) # --upload-url (default: Sonatype Central Portal release URL) +# --snapshot-url (default: Sonatype Central snapshot repo) # # Environment: -# SONATYPE_BEARER – Bearer token for authentication (required) +# SONATYPE_BEARER – Bearer token for authentication (required unless --dry-run) # # ------------------------------------------------------------------------- set -euo pipefail # ---- defaults ----------------------------------------------------------- UPLOAD_URL="https://central.sonatype.com/api/v1/publisher/upload" +SNAPSHOT_URL="https://central.sonatype.com/repository/maven-snapshots/" PUBLISHING_TYPE="USER_MANAGED" # USER_MANAGED (manual) or AUTOMATIC DEPLOYMENT_NAME="" +SNAPSHOT=false RELEASE_DIR="" # ------------------------------------------------------------------------- -analyze_versions() { - local versions - local snapshot_versions - local release_versions - - # Derive versions from Maven repository paths: - # group/path/artifact/version/artifact-version*.pom - versions=$(find "${RELEASE_DIR}" -type f -name '*.pom' -print0 | while IFS= read -r -d '' file; do - rel_path="${file#"${RELEASE_DIR}"/}" - rel_dir="$(dirname "${rel_path}")" - version="$(echo "${rel_dir}" | awk -F/ '{print $(NF)}')" - - if [[ -n "${version}" ]]; then - echo "${version}" - fi - done | sed '/^$/d' | awk '!seen[$0]++') - - snapshot_versions=$(printf '%s\n' "${versions}" | grep -- '-SNAPSHOT$' | sed '/^$/d' | paste -sd, - || true) - release_versions=$(printf '%s\n' "${versions}" | grep -v -- '-SNAPSHOT$' | sed '/^$/d' | paste -sd, - || true) - - if [[ -n "${snapshot_versions}" && -n "${release_versions}" ]]; then - echo "Error: mixed versions detected in ${RELEASE_DIR}:" >&2 - echo " SNAPSHOT versions: ${snapshot_versions}" >&2 - echo " Release versions: ${release_versions}" >&2 - echo "Please upload snapshots and releases separately." >&2 - exit 1 - fi - - if [[ -n "${snapshot_versions}" ]]; then - DETECTED_VERSION_MODE="snapshot" - DETECTED_VERSION_LIST="${snapshot_versions}" - elif [[ -n "${release_versions}" ]]; then - DETECTED_VERSION_MODE="release" - DETECTED_VERSION_LIST="${release_versions}" - else - DETECTED_VERSION_MODE="unknown" - DETECTED_VERSION_LIST="" - fi -} - detect_groupid() { local path_groups @@ -102,6 +68,33 @@ detect_groupid() { echo "unknown-group" } +detect_versions() { + find "${RELEASE_DIR}" -type f | while IFS= read -r file; do + rel_path="${file#"${RELEASE_DIR}"/}" + file_name="$(basename "${rel_path}")" + + # Ignore artifact-level metadata files (no version segment in path) + if [[ "${file_name}" == "maven-metadata.xml"* ]]; then + continue + fi + + # Maven repository layout expected for versioned artifacts: + # group/path/artifact/version/artifact-version.ext[.sha1|.md5|...] + version_dir="$(basename "$(dirname "${rel_path}")")" + + if [[ -n "${version_dir}" ]]; then + echo "${version_dir}" + fi + done | sed '/^$/d' | awk '!seen[$0]++' +} + +require_sonatype_bearer() { + if [[ -z "${SONATYPE_BEARER:-}" ]]; then + echo "Error: SONATYPE_BEARER environment variable is required." >&2 + exit 1 + fi +} + usage() { cat <<-EOF Usage: $(basename "$0") [options] @@ -109,13 +102,15 @@ usage() { Upload a local Maven repository folder to Sonatype Central Portal. Options: + --snapshot Deploy as snapshot --publishing-type Publishing type (default: USER_MANAGED) --name Deployment name (default: auto-generated) --upload-url Release upload endpoint URL + --snapshot-url Snapshot repository URL -h, --help Show this help message Environment: - SONATYPE_BEARER Bearer token for authentication (required) + SONATYPE_BEARER Bearer token for authentication (required unless --dry-run) EOF exit "${1:-0}" } @@ -123,9 +118,11 @@ usage() { # ---- parse arguments ---------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in + --snapshot) SNAPSHOT=true; shift ;; --publishing-type) PUBLISHING_TYPE="$2"; shift 2 ;; --name) DEPLOYMENT_NAME="$2"; shift 2 ;; --upload-url) UPLOAD_URL="$2"; shift 2 ;; + --snapshot-url) SNAPSHOT_URL="$2"; shift 2 ;; -h|--help) usage 0 ;; -*) echo "Unknown option: $1" >&2; usage 1 ;; *) RELEASE_DIR="$1"; shift ;; @@ -142,20 +139,7 @@ if [[ ! -d "${RELEASE_DIR}" ]]; then exit 1 fi -: "${SONATYPE_BEARER:?Error: SONATYPE_BEARER environment variable is not set}" - -# ---- validate repository version mode ----------------------------------- -analyze_versions -echo "Detected version mode: ${DETECTED_VERSION_MODE}" -if [[ -n "${DETECTED_VERSION_LIST}" ]]; then - echo "Detected versions: ${DETECTED_VERSION_LIST}" -fi - -if [[ "${DETECTED_VERSION_MODE}" == "snapshot" ]]; then - echo "Skipping upload: only SNAPSHOT versions were found in ${RELEASE_DIR}." >&2 - echo "This script uploads release bundles only." >&2 - exit 0 -fi +RELEASE_DIR="${RELEASE_DIR%/}" # Deployment ID file stored beside the release dir DEPLOYMENTID_FILE="${RELEASE_DIR%/}_DEPLOYMENTID.txt" @@ -163,7 +147,41 @@ DEPLOYMENTID_FILE="${RELEASE_DIR%/}_DEPLOYMENTID.txt" # ---- build deployment name ---------------------------------------------- if [[ -z "${DEPLOYMENT_NAME}" ]]; then GROUP_ID=$(detect_groupid) - DEPLOYMENT_NAME="uploaded ${GROUP_ID} on $(date '+%Y%m%d-%H%M%S')" + if [[ "${SNAPSHOT}" == "true" ]]; then + DEPLOYMENT_NAME="uploaded ${GROUP_ID} on $(date '+%Y%m%d-%H%M%S')" + else + DEPLOYMENT_NAME="uploaded ${GROUP_ID} on $(date '+%Y%m%d-%H%M%S')" + fi +fi + +# ---- snapshot deployment ------------------------------------------------ +if [[ "${SNAPSHOT}" == "true" ]]; then + echo "Deploying snapshots to ${SNAPSHOT_URL} ..." + + # Ensure trailing slash + SNAPSHOT_BASE="${SNAPSHOT_URL%/}/" + + find "${RELEASE_DIR}" -type f | while IFS= read -r file; do + # Compute the relative path within the release dir + REL_PATH="${file#"${RELEASE_DIR}"}" + REL_PATH="${REL_PATH#/}" + + TARGET_URL="${SNAPSHOT_BASE}${REL_PATH}" + + echo " PUT ${REL_PATH}" + HTTP_CODE=$(curl -sS -w '%{http_code}' -o /dev/null \ + -H "Authorization: Bearer ${SONATYPE_BEARER}" \ + --upload-file "${file}" \ + "${TARGET_URL}") + + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -ge 300 ]]; then + echo "Error: Upload of ${REL_PATH} failed with HTTP ${HTTP_CODE}" >&2 + exit 1 + fi + done + + echo "Snapshot deployment completed successfully." + exit 0 fi # ---- release deployment: create bundle zip ------------------------------ @@ -192,6 +210,8 @@ echo " URL: ${UPLOAD_URL}" echo " Publishing type: ${PUBLISHING_TYPE}" echo " Name: ${DEPLOYMENT_NAME}" +require_sonatype_bearer + HTTP_RESPONSE="${TMPDIR:-/tmp}/sonatype-response-$$.txt" trap 'rm -f "${BUNDLE_ZIP}" "${HTTP_RESPONSE}"' EXIT diff --git a/.github/specs/p2-mirror-service.md b/.github/specs/p2-mirror-service.md index d2d456a..07ca2c0 100644 --- a/.github/specs/p2-mirror-service.md +++ b/.github/specs/p2-mirror-service.md @@ -4,10 +4,9 @@ use the existing implementation `P2Mirror.java` and the project it is contained Make it a DS factory service spawning new instances for each configuration provided. Create a configuration interface `@P2MirrorConfig` containing the repo root folder location with the LOCAL_ROOT_URI as default and the other options from p2 mirroring with meaningful defaults like currently in the P2Mirror implementation provided. -Comment the properties inside the config +Comment the properties inside the config using OSGi metadata capabilities -Create an api sub bundle containing exported P2Mirror api class with a method mirror(URI repo) which the P2Mirror service is implementing - -create a gogo sub bundle containing command name `mirrorRepo(URI sourceURI)` using the api to mirror - -create a new bnd test project \ No newline at end of file +* create an api sub bundle containing exported P2Mirror api class with a method mirror(URI repo) which the P2Mirror service is implementing +* create a gogo sub bundle containing command name `mirrorRepo(URI sourceURI)` using the api to mirror +* create a new bnd test project with testdata and create OSGi test for mirroring a file and http based repo +* create testcase for spawning and mirroring multiple repos simultaneously diff --git a/.github/specs/sample-p2-project.md b/.github/specs/sample-p2-project.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c718de8..538cd68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,14 +32,15 @@ jobs: cache: gradle - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 - name: Release Build env: SONATYPE_BEARER: ${{ secrets.SONATYPE_BEARER }} + GITHUB_BEARER: ${{ secrets.GH_PAT_public_read_example_pde_rcp_2026 }} run: | ./gradlew --version - ./gradlew --no-daemon clean release + ./gradlew --no-daemon clean build testOSGi release - name: Collect dist file list id: collect-dist-files @@ -79,7 +80,7 @@ jobs: steps: - name: Download dist bundle - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: dist-bundle-${{ github.event.repository.name }}-${{ needs.build-release.outputs.timestamp }} path: . @@ -139,9 +140,9 @@ jobs: cache: gradle - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c + uses: gradle/actions/wrapper-validation@748248ddd2a24f49513d8f472f81c3a07d4d50e1 - name: Build (no artifact upload) env: - SONATYPE_BEARER: ${{ secrets.SONATYPE_BEARER }} + GITHUB_BEARER: ${{ secrets.GH_PAT_public_read_example_pde_rcp_2026 }} run: ./gradlew --no-daemon clean build diff --git a/README.md b/README.md index 14caba9..557dd4b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ # build ./gradlew clean build +# OSGi tests can be bounded globally via Gradle property (default: 5 min) +./gradlew testOSGi -PtestTaskTimeoutMinutes=10 + # release export SONATYPE_BEARER= export GPG_KEYNAME= diff --git a/build.gradle b/build.gradle index 43ede09..593f7b2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,29 @@ +import java.time.Duration + +def testTaskTimeoutMinutes = providers + .gradleProperty('testTaskTimeoutMinutes') + .map { it as int } + .orElse(5) + subprojects { tasks.withType(Test).configureEach { failOnNoDiscoveredTests = false + timeout = Duration.ofMinutes(testTaskTimeoutMinutes.get()) } + + tasks + .matching { task -> task.name.toLowerCase().startsWith('test') } + .configureEach { task -> + task.timeout = Duration.ofMinutes(testTaskTimeoutMinutes.get()) + } +} + +gradle.taskGraph.whenReady { graph -> + graph.allTasks + .findAll { task -> task.name.toLowerCase().startsWith('test') && task.hasProperty('timeout') } + .each { task -> + task.timeout = Duration.ofMinutes(testTaskTimeoutMinutes.get()) + } } tasks.register('sonatypeUpload', Exec) { diff --git a/cnf/build.bnd b/cnf/build.bnd index e6e9f58..fe372b4 100644 --- a/cnf/build.bnd +++ b/cnf/build.bnd @@ -66,7 +66,7 @@ gpg: gpg --homedir ${def;USERHOME;~}/.gnupg --pinentry-mode loopback org.apache.aries.spifly.dynamic.framework.extension; startlevel=1,\ org.apache.aries.spifly.dynamic.bundle; startlevel=2,\ org.apache.felix.scr; startlevel=3,\ - io.klib.app.p2.mirror.service; startlevel=6,\ + io.klib.app.p2.mirror.*; startlevel=6,\ *; startlevel=5, -make: (*).(jar);type=bnd; recipe="bnd/$1.bnd" diff --git a/cnf/ext/junit.bnd b/cnf/ext/junit.bnd index 8070dfd..5d12f8f 100644 --- a/cnf/ext/junit.bnd +++ b/cnf/ext/junit.bnd @@ -42,15 +42,15 @@ junit-osgi: \ junit-platform-launcher;version='${range;[===,==+);${junit.platform.version}}',\ assertj-core;version=latest,\ net.bytebuddy.byte-buddy;version=latest,\ - org.opentest4j;version=latest,\ + org.opentest4j;version='${range;[===,==+);${opentest4j.version}}',\ org.apiguardian:apiguardian-api;version=latest,\ - junit-jupiter-api;version=latest,\ - junit-jupiter-engine;version=latest,\ - junit-jupiter-params;version=latest,\ + junit-jupiter-api;version='${range;[===,==+);${junit.jupiter.version}}',\ + junit-jupiter-engine;version='${range;[===,==+);${junit.jupiter.version}}',\ + junit-jupiter-params;version='${range;[===,==+);${junit.jupiter.version}}',\ org.hamcrest;version=latest,\ org.awaitility;version=latest,\ org.apache.servicemix.bundles.junit;version=latest,\ - junit-vintage-engine;version=latest,\ + junit-vintage-engine;version='${range;[===,==+);${junit.jupiter.version}}',\ org.osgi.service.coordinator;version=latest,\ org.osgi.service.log;version=latest,\ org.osgi.service.repository;version=latest,\ diff --git a/cnf/ext/repositories.bnd b/cnf/ext/repositories.bnd index 2fe7fae..aee1828 100644 --- a/cnf/ext/repositories.bnd +++ b/cnf/ext/repositories.bnd @@ -24,7 +24,8 @@ sonatype_bearer: ${env;SONATYPE_BEARER} tags = '-'; \ name = "Release";\ releaseUrl = ${fileuri;${releaserepo}};\ - snapshotUrl = ${fileuri;${releaserepo}},\ + snapshotUrl = ${fileuri;${releaserepo}};\ + noupdateOnRelease=true,\ aQute.bnd.repository.maven.provider.MavenBndRepository;\ tags = 'Baseline'; \ name = "Baseline";\ diff --git a/cnf/sonatypesnapshots.mvn b/cnf/sonatypesnapshots.mvn new file mode 100644 index 0000000..c91e50f --- /dev/null +++ b/cnf/sonatypesnapshots.mvn @@ -0,0 +1,4 @@ +io.klib.services:io.klib.app.p2.mirror.test:0.2.0-SNAPSHOT +io.klib.services:io.klib.app.p2.mirror.api:0.2.0-SNAPSHOT +io.klib.services:io.klib.app.p2.mirror.gogo:0.2.0-SNAPSHOT +io.klib.services:io.klib.app.p2.mirror.service:0.2.0-SNAPSHOT diff --git a/exec/bnd.bnd b/exec/bnd.bnd index 7b32623..15545f3 100644 --- a/exec/bnd.bnd +++ b/exec/bnd.bnd @@ -1,6 +1,5 @@ --dependson: io.klib.app.p2.mirror -sub: used_only_for_executables - +-dependson: io.klib.app.p2.mirror # exporting executable FAT jars -groupid: io.klib.apps @@ -11,6 +10,6 @@ bsn=p2.mirror.app; \ version=${Bundle-Version}, \ \ - ${workspace}/io.klib.app.p2.mirror/launchMirror.bndrun; \ + ${workspace}/io.klib.app.p2.mirror/launchMirror_win32.bndrun; \ name=p2-mirror-app-win32.jar; \ bsn=p2.mirror.app.win32 diff --git a/gradle.properties b/gradle.properties index e902f94..fceb4ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,3 +6,6 @@ bnd_version=7.3.0-SNAPSHOT # The URLs to the repos for the Bnd Gradle plugin bnd_snapshots=https://bndtools.jfrog.io/bndtools/libs-snapshot-local bnd_releases=https://bndtools.jfrog.io/bndtools/libs-release-local + +# Global timeout for all test-related Gradle tasks (including testOSGi) +testTaskTimeoutMinutes=5 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc..61285a6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbc3ce4..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0262dcb..adff685 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/io.klib.app.p2.mirror.test/.classpath b/io.klib.app.p2.mirror.test/.classpath new file mode 100644 index 0000000..8a5edb8 --- /dev/null +++ b/io.klib.app.p2.mirror.test/.classpath @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/io.klib.app.p2.mirror.test/.project b/io.klib.app.p2.mirror.test/.project new file mode 100644 index 0000000..4e4fc94 --- /dev/null +++ b/io.klib.app.p2.mirror.test/.project @@ -0,0 +1,23 @@ + + + io.klib.app.p2.mirror.test + + + + + + org.eclipse.jdt.core.javabuilder + + + + + bndtools.core.bndbuilder + + + + + + org.eclipse.jdt.core.javanature + bndtools.core.bndnature + + diff --git a/io.klib.app.p2.mirror.test/.settings/org.eclipse.core.resources.prefs b/io.klib.app.p2.mirror.test/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/io.klib.app.p2.mirror.test/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/io.klib.app.p2.mirror.test/bnd.bnd b/io.klib.app.p2.mirror.test/bnd.bnd new file mode 100644 index 0000000..fe414e9 --- /dev/null +++ b/io.klib.app.p2.mirror.test/bnd.bnd @@ -0,0 +1,187 @@ +-dependson: io.klib.app.p2.mirror + +-buildpath: \ + io.klib.app.p2.mirror.api,\ + osgi.core,\ + org.osgi.service.cm,\ + junit-jupiter-api;version='[5.10,6)',\ + junit-platform-commons;version='[1.10,2)',\ + org.opentest4j;version='[1.3,2)',\ + org.apiguardian.api + +-testpath: \ + junit-jupiter-api;version='[5.10,6)',\ + junit-jupiter-engine;version='[5.10,6)',\ + junit-platform-commons;version='[1.10,2)',\ + junit-platform-engine;version='[1.10,2)',\ + junit-platform-launcher;version='[1.10,2)',\ + org.opentest4j;version='[1.3,2)',\ + org.osgi.test.common;version=latest,\ + org.osgi.test.junit5;version=latest + +-tester: biz.aQute.tester.junit-platform + +Test-Cases: ${classes;HIERARCHY_INDIRECTLY_ANNOTATED;org.junit.platform.commons.annotation.Testable;CONCRETE;PUBLIC} + +-runee: JavaSE-21 +-runfw: org.eclipse.osgi;version='[3.24,4)' + +-resolve: auto +-resolve.effective: active +-runprovidedcapabilities: ${native_capability} +-runpath: org.apache.aries.spifly.dynamic.framework.extension +-runvm: \ + -Dosgi.framework.extensions=org.apache.aries.spifly.dynamic.framework.extension \ + -Dorg.eclipse.ecf.provider.filetransfer.retrieve.multithreaded=true \ + -Dorg.eclipse.ecf.provider.filetransfer.retrieve.readTimeout=15000 \ + -Dorg.eclipse.ecf.provider.filetransfer.retrieve.connectTimeout=10000 \ + -Dorg.eclipse.ecf.provider.filetransfer.retrieve.workerThreads=16 \ + -Declipse.p2.max.threads=16 -Declipse.p2.force.threading=true \ + -DGITHUB_BEARER=${env;GITHUB_BEARER} + +-runproperties: \ + osgi.console=,\ + org.osgi.framework.bootdelegation='javax.*,org.xml.sax,org.xml.sax.helpers',\ + osgi.console.enable.builtin=false,\ + org.osgi.framework.system.packages.extra='javax.*,org.xml.sax,org.xml.sax.helpers',\ + eclipse.p2.max.threads=16,\ + launch.trace=false,\ + tester.continuous=false,\ + tester.trace=true + +bundles.p2.director: \ + bnd.identity;id='org.eclipse.core.jobs',\ + bnd.identity;id='org.eclipse.ecf',\ + bnd.identity;id='org.eclipse.ecf.filetransfer',\ + bnd.identity;id='org.eclipse.ecf.identity',\ + bnd.identity;id='org.eclipse.ecf.provider.filetransfer',\ + bnd.identity;id='org.eclipse.ecf.provider.filetransfer.httpclient5',\ + bnd.identity;id='org.eclipse.ecf.provider.filetransfer.httpclientjava',\ + bnd.identity;id='org.eclipse.equinox.app',\ + bnd.identity;id='org.eclipse.equinox.common',\ + bnd.identity;id='org.eclipse.equinox.frameworkadmin',\ + bnd.identity;id='org.eclipse.equinox.frameworkadmin.equinox',\ + bnd.identity;id='org.eclipse.equinox.p2.artifact.repository',\ + bnd.identity;id='org.eclipse.equinox.p2.console',\ + bnd.identity;id='org.eclipse.equinox.p2.core',\ + bnd.identity;id='org.eclipse.equinox.p2.director',\ + bnd.identity;id='org.eclipse.equinox.p2.director.app',\ + bnd.identity;id='org.eclipse.equinox.p2.engine',\ + bnd.identity;id='org.eclipse.equinox.p2.jarprocessor',\ + bnd.identity;id='org.eclipse.equinox.p2.metadata',\ + bnd.identity;id='org.eclipse.equinox.p2.metadata.repository',\ + bnd.identity;id='org.eclipse.equinox.p2.operations',\ + bnd.identity;id='org.eclipse.equinox.p2.transport.ecf',\ + bnd.identity;id='org.eclipse.equinox.p2.touchpoint.eclipse',\ + bnd.identity;id='org.eclipse.equinox.p2.touchpoint.natives',\ + bnd.identity;id='org.eclipse.equinox.registry',\ + bnd.identity;id='org.eclipse.osgi.services',\ + bnd.identity;id='org.sat4j.core',\ + bnd.identity;id='org.sat4j.pb' + +-runblacklist: \ + bnd.identity;id='osgi.*',\ + bnd.identity;id='slf4j.nop' + +-augment: \ + org.eclipse.equinox.p2.core; \ + capability:='osgi.service;objectClass=org.eclipse.equinox.p2.core.IProvisioningAgentProvider' + +-runrequires: \ + ${bundles.p2.director},\ + bnd.identity;id='org.osgi.service.cm',\ + bnd.identity;id='org.apache.felix.configadmin';version='[1.9.10,2.0.0)',\ + bnd.identity;id='org.apache.felix.configadmin',\ + bnd.identity;id='io.klib.app.p2.mirror.api',\ + bnd.identity;id='io.klib.app.p2.mirror.service',\ + bnd.identity;id='io.klib.app.p2.mirror.test',\ + bnd.identity;id='org.apache.felix.configadmin',\ + bnd.identity;id=junit-jupiter-engine +-runbundles: \ + bcpg;version='[1.82.0,1.82.1)';startlevel=5,\ + bcprov;version='[1.82.0,1.82.1)';startlevel=5,\ + bcutil;version='[1.82.0,1.82.1)';startlevel=5,\ + ch.qos.logback.core;version='[1.2.11,1.2.12)';startlevel=1,\ + jcl.over.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ + jul.to.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ + log4j.over.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ + org.apache.aries.spifly.dynamic.bundle;version='[1.3.7,1.3.8)';startlevel=2,\ + org.apache.aries.spifly.dynamic.framework.extension;version='[1.2.1,1.2.2)';startlevel=1,\ + org.apache.felix.configadmin;version='[1.9.10,1.9.11)';startlevel=5,\ + org.apache.felix.scr;version='[2.2.14,2.2.15)';startlevel=3,\ + org.apache.httpcomponents.client5.httpclient5;version='[5.5.1,5.5.2)';startlevel=5,\ + org.apache.httpcomponents.core5.httpcore5;version='[5.3.6,5.3.7)';startlevel=5,\ + org.apache.httpcomponents.core5.httpcore5-h2;version='[5.3.6,5.3.7)';startlevel=5,\ + org.eclipse.core.contenttype;version='[3.9.800,3.9.801)';startlevel=5,\ + org.eclipse.core.jobs;version='[3.15.700,3.15.701)';startlevel=5,\ + org.eclipse.core.runtime;version='[3.34.100,3.34.101)';startlevel=5,\ + org.eclipse.ecf;version='[3.13.0,3.13.1)';startlevel=5,\ + org.eclipse.ecf.filetransfer;version='[5.1.103,5.1.104)';startlevel=5,\ + org.eclipse.ecf.identity;version='[3.10.0,3.10.1)';startlevel=5,\ + org.eclipse.ecf.provider.filetransfer;version='[3.3.100,3.3.101)';startlevel=5,\ + org.eclipse.ecf.provider.filetransfer.httpclient5;version='[1.1.101,1.1.102)';startlevel=5,\ + org.eclipse.ecf.provider.filetransfer.httpclientjava;version='[2.1.1,2.1.2)';startlevel=5,\ + org.eclipse.equinox.app;version='[1.7.500,1.7.501)';startlevel=5,\ + org.eclipse.equinox.common;version='[3.20.300,3.20.301)';startlevel=5,\ + org.eclipse.equinox.concurrent;version='[1.3.400,1.3.401)';startlevel=5,\ + org.eclipse.equinox.frameworkadmin;version='[2.3.500,2.3.501)';startlevel=5,\ + org.eclipse.equinox.frameworkadmin.equinox;version='[1.3.400,1.3.401)';startlevel=5,\ + org.eclipse.equinox.http.service.api;version='[1.2.102,1.2.103)';startlevel=5,\ + org.eclipse.equinox.p2.artifact.repository;version='[1.5.800,1.5.801)';startlevel=5,\ + org.eclipse.equinox.p2.console;version='[1.3.700,1.3.701)';startlevel=5,\ + org.eclipse.equinox.p2.core;version='[2.13.200,2.13.201)';startlevel=5,\ + org.eclipse.equinox.p2.director;version='[2.6.800,2.6.801)';startlevel=5,\ + org.eclipse.equinox.p2.director.app;version='[1.3.800,1.3.801)';startlevel=5,\ + org.eclipse.equinox.p2.engine;version='[2.11.0,2.11.1)';startlevel=5,\ + org.eclipse.equinox.p2.garbagecollector;version='[1.3.700,1.3.701)';startlevel=5,\ + org.eclipse.equinox.p2.jarprocessor;version='[1.3.600,1.3.601)';startlevel=5,\ + org.eclipse.equinox.p2.metadata;version='[2.9.600,2.9.601)';startlevel=5,\ + org.eclipse.equinox.p2.metadata.repository;version='[1.5.700,1.5.701)';startlevel=5,\ + org.eclipse.equinox.p2.operations;version='[2.7.700,2.7.701)';startlevel=5,\ + org.eclipse.equinox.p2.publisher;version='[1.9.600,1.9.601)';startlevel=5,\ + org.eclipse.equinox.p2.publisher.eclipse;version='[1.6.700,1.6.701)';startlevel=5,\ + org.eclipse.equinox.p2.repository;version='[2.9.600,2.9.601)';startlevel=5,\ + org.eclipse.equinox.p2.repository.tools;version='[2.4.900,2.4.901)';startlevel=5,\ + org.eclipse.equinox.p2.touchpoint.eclipse;version='[2.4.600,2.4.601)';startlevel=5,\ + org.eclipse.equinox.p2.touchpoint.natives;version='[1.5.800,1.5.801)';startlevel=5,\ + org.eclipse.equinox.p2.transport.ecf;version='[1.4.600,1.4.601)';startlevel=5,\ + org.eclipse.equinox.preferences;version='[3.12.100,3.12.101)';startlevel=5,\ + org.eclipse.equinox.registry;version='[3.12.600,3.12.601)';startlevel=5,\ + org.eclipse.equinox.security;version='[1.4.700,1.4.701)';startlevel=5,\ + org.eclipse.equinox.simpleconfigurator;version='[1.5.700,1.5.701)';startlevel=5,\ + org.eclipse.equinox.simpleconfigurator.manipulator;version='[2.3.600,2.3.601)';startlevel=5,\ + org.eclipse.jetty.servlet-api;version='[4.0.6,4.0.7)';startlevel=5,\ + org.eclipse.osgi.services;version='[3.12.300,3.12.301)';startlevel=5,\ + org.objectweb.asm;version='[9.9.0,9.9.1)';startlevel=5,\ + org.objectweb.asm.commons;version='[9.9.0,9.9.1)';startlevel=5,\ + org.objectweb.asm.tree;version='[9.9.0,9.9.1)';startlevel=5,\ + org.objectweb.asm.tree.analysis;version='[9.9.0,9.9.1)';startlevel=5,\ + org.objectweb.asm.util;version='[9.9.0,9.9.1)';startlevel=5,\ + org.osgi.service.cm;version='[1.6.1,1.6.2)';startlevel=5,\ + org.osgi.service.component;version='[1.5.1,1.5.2)';startlevel=5,\ + org.osgi.service.device;version='[1.1.1,1.1.2)';startlevel=5,\ + org.osgi.service.event;version='[1.4.1,1.4.2)';startlevel=5,\ + org.osgi.service.http.whiteboard;version='[1.1.1,1.1.2)';startlevel=5,\ + org.osgi.service.metatype;version='[1.4.1,1.4.2)';startlevel=5,\ + org.osgi.service.prefs;version='[1.1.2,1.1.3)';startlevel=5,\ + org.osgi.service.provisioning;version='[1.2.0,1.2.1)';startlevel=5,\ + org.osgi.service.upnp;version='[1.2.1,1.2.2)';startlevel=5,\ + org.osgi.service.useradmin;version='[1.1.1,1.1.2)';startlevel=5,\ + org.osgi.service.wireadmin;version='[1.0.2,1.0.3)';startlevel=5,\ + org.osgi.util.function;version='[1.2.0,1.2.1)';startlevel=5,\ + org.osgi.util.promise;version='[1.3.0,1.3.1)';startlevel=5,\ + org.sat4j.core;version='[2.3.6,2.3.7)';startlevel=5,\ + org.sat4j.pb;version='[2.3.6,2.3.7)';startlevel=5,\ + org.tukaani.xz;version='[1.11.0,1.11.1)';startlevel=5,\ + slf4j.api;version='[2.0.17,2.0.18)';startlevel=5,\ + slf4j.osgi;version='[2.0.0,2.0.1)';startlevel=1,\ + io.klib.app.p2.mirror.api;version=snapshot;startlevel=6,\ + io.klib.app.p2.mirror.test;version=snapshot;startlevel=6,\ + junit-jupiter-api;version='${range;[===,==+);${junit.jupiter.version}}';startlevel=5,\ + junit-jupiter-engine;version='${range;[===,==+);${junit.jupiter.version}}';startlevel=5,\ + junit-platform-commons;version='${range;[===,==+);${junit.platform.version}}';startlevel=5,\ + junit-platform-engine;version='${range;[===,==+);${junit.platform.version}}';startlevel=5,\ + junit-platform-launcher;version='${range;[===,==+);${junit.platform.version}}';startlevel=5,\ + org.opentest4j;version='[1.3.0,1.3.1)';startlevel=5,\ + io.klib.app.p2.mirror.service;version=snapshot;startlevel=6,\ + org.apache.felix.configadmin;version='[1.9.10,1.9.11)';startlevel=5 diff --git a/io.klib.app.p2.mirror.test/src/io/klib/app/p2/mirror/test/P2MirrorOsgiTest.java b/io.klib.app.p2.mirror.test/src/io/klib/app/p2/mirror/test/P2MirrorOsgiTest.java new file mode 100644 index 0000000..9a69c16 --- /dev/null +++ b/io.klib.app.p2.mirror.test/src/io/klib/app/p2/mirror/test/P2MirrorOsgiTest.java @@ -0,0 +1,242 @@ +package io.klib.app.p2.mirror.test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +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.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Dictionary; +import java.util.Hashtable; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.InvalidSyntaxException; +import org.osgi.framework.ServiceReference; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; + +import io.klib.app.p2.mirror.api.P2Mirror; + +@Timeout(value = 15, unit = TimeUnit.MINUTES) +public class P2MirrorOsgiTest { + + private static final Logger LOG = Logger.getLogger(P2MirrorOsgiTest.class.getName()); + private static final String STARTUP_CHECKPOINT = "CHECKPOINT P2MirrorOsgiTest.setupClass entered"; + private static final Duration SERVICE_WAIT = Duration.ofSeconds(30); + private static final Duration DOWNLOAD_CONNECT_TIMEOUT = Duration.ofSeconds(60); + private static final Duration DOWNLOAD_REQUEST_TIMEOUT = Duration.ofMinutes(5); + private static final String FACTORY_PID = "io.klib.app.p2.mirror.service.P2Mirror"; + private static final String ARCHIVE_URL = "https://github.com/klibio/example.pde.rcp/releases/download/latest-main/repo.binary.zip"; + private static final Path TESTDATA_DIR = Paths.get(System.getProperty("user.dir"), "testdata"); + private static final Path ARCHIVE_PATH = TESTDATA_DIR.resolve("repo.binary.zip"); + + private static URI preparedSourceRepoUri; + + private final List createdConfigurations = new ArrayList<>(); + + @BeforeAll + static void setupClass() throws Exception { + LOG.info(STARTUP_CHECKPOINT); + System.err.println(STARTUP_CHECKPOINT); + System.err.flush(); + System.out.println(STARTUP_CHECKPOINT); + System.out.flush(); + downloadArchiveIfMissing(); + preparedSourceRepoUri = createArchiveSourceUri(); + } + + @AfterEach + void cleanupConfigurations() throws IOException { + for (Configuration configuration : createdConfigurations) { + if (configuration != null) { + configuration.delete(); + } + } + createdConfigurations.clear(); + } + + @Test + void mirrorsFileRepository() throws Exception { + URI sourceRepo = findSourceRepoUri(); + assertJarFileSourceRepo(sourceRepo); + Path mirrorRoot = Files.createTempDirectory("p2-mirror-file-"); + + P2Mirror mirrorService = createMirrorService("file-instance", mirrorRoot); + mirrorService.mirror(sourceRepo); + + assertTrue(containsFile(mirrorRoot, "p2.index"), "Expected mirrored metadata p2.index"); + } + + @Test + void mirrorsWithMultipleFactoryInstancesConcurrently() throws Exception { + URI sourceRepo = findSourceRepoUri(); + assertJarFileSourceRepo(sourceRepo); + Path mirrorRootA = Files.createTempDirectory("p2-mirror-multi-a-"); + Path mirrorRootB = Files.createTempDirectory("p2-mirror-multi-b-"); + + P2Mirror mirrorServiceA = createMirrorService("multi-a", mirrorRootA); + P2Mirror mirrorServiceB = createMirrorService("multi-b", mirrorRootB); + + ExecutorService executorService = Executors.newFixedThreadPool(2); + try { + List> jobs = List.of( + () -> { + mirrorServiceA.mirror(sourceRepo); + return null; + }, + () -> { + mirrorServiceB.mirror(sourceRepo); + return null; + } + ); + List> futures = executorService.invokeAll(jobs); + for (Future future : futures) { + future.get(2, TimeUnit.MINUTES); + } + } finally { + executorService.shutdownNow(); + } + + assertTrue(containsFile(mirrorRootA, "p2.index"), "Expected mirrored metadata p2.index in instance A"); + assertTrue(containsFile(mirrorRootB, "p2.index"), "Expected mirrored metadata p2.index in instance B"); + } + + private P2Mirror createMirrorService(String destinationName, Path mirrorRoot) throws Exception { + BundleContext bundleContext = bundleContext(); + ConfigurationAdmin configurationAdmin = getService(bundleContext, ConfigurationAdmin.class); + + Configuration configuration = configurationAdmin.createFactoryConfiguration(FACTORY_PID, "?"); + Dictionary properties = new Hashtable<>(); + properties.put("repoRootUri", mirrorRoot.toUri().toString()); + properties.put("destinationName", destinationName); + properties.put("agentUri", mirrorRoot.resolve("agent").toUri().toString()); + properties.put("downloadMetadata", Boolean.TRUE); + properties.put("verbose", Boolean.FALSE); + configuration.update(properties); + createdConfigurations.add(configuration); + + return waitForMirrorService(bundleContext, destinationName, SERVICE_WAIT); + } + + private BundleContext bundleContext() { + Bundle bundle = FrameworkUtil.getBundle(getClass()); + assertNotNull(bundle, "Test must run in OSGi framework"); + return bundle.getBundleContext(); + } + + private T getService(BundleContext context, Class type) { + ServiceReference reference = context.getServiceReference(type); + assertNotNull(reference, "Missing required service " + type.getName()); + T service = context.getService(reference); + assertNotNull(service, "Service unavailable for " + type.getName()); + return service; + } + + private P2Mirror waitForMirrorService(BundleContext context, String destinationName, Duration timeout) + throws InterruptedException, InvalidSyntaxException { + String filter = "(destinationName=" + destinationName + ")"; + long timeoutAt = System.currentTimeMillis() + timeout.toMillis(); + while (System.currentTimeMillis() < timeoutAt) { + ServiceReference reference = context.getServiceReferences(P2Mirror.class, filter) + .stream() + .findFirst() + .orElse(null); + if (reference != null) { + P2Mirror service = context.getService(reference); + if (service != null) { + return service; + } + } + Thread.sleep(200); + } + throw new IllegalStateException("Timed out waiting for P2Mirror service with destinationName=" + destinationName); + } + + private boolean containsFile(Path root, String fileName) throws IOException { + try (Stream stream = Files.walk(root)) { + return stream.anyMatch(path -> path.getFileName().toString().equals(fileName)); + } + } + + private void assertJarFileSourceRepo(URI sourceRepo) { + assertTrue("jar".equalsIgnoreCase(sourceRepo.getScheme()), "Expected jar:file source URI but got " + sourceRepo); + assertTrue(sourceRepo.toString().startsWith("jar:file:"), "Expected jar:file source URI but got " + sourceRepo); + assertTrue(sourceRepo.toString().endsWith("!/"), "Expected archive root URI ending with !/ but got " + sourceRepo); + } + + private URI findSourceRepoUri() { + if (preparedSourceRepoUri != null) { + return preparedSourceRepoUri; + } + throw new IllegalStateException("Cannot determine prepared jar:file source URI for " + ARCHIVE_PATH); + } + + private static void downloadArchiveIfMissing() throws IOException, InterruptedException { + if (Files.exists(ARCHIVE_PATH)) { + return; + } + + Files.createDirectories(TESTDATA_DIR); + + String githubBearer = System.getenv("GITHUB_BEARER"); + if (githubBearer == null || githubBearer.isBlank()) { + githubBearer = System.getProperty("GITHUB_BEARER"); + } + if (githubBearer == null || githubBearer.isBlank()) { + fail("Missing GITHUB_BEARER. Provide it as an environment variable or JVM system property (-DGITHUB_BEARER=...) and retry:\n\n" + + "ALTERNATIVELY download via curl int testdata folder\n" + + "export GITHUB_BEARER=\n" + + "curl -L \\\n" + + " -H \"Accept: application/vnd.github+json\" \\\n" + + " -H \"Authorization: Bearer $GITHUB_BEARER\" \\\n" + + " " + ARCHIVE_URL); + } + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(DOWNLOAD_CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(ARCHIVE_URL)) + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer " + githubBearer) + .timeout(DOWNLOAD_REQUEST_TIMEOUT) + .GET() + .build(); + + Path temporaryArchive = ARCHIVE_PATH.resolveSibling(ARCHIVE_PATH.getFileName() + ".part"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(temporaryArchive)); + if (response.statusCode() >= 400) { + Files.deleteIfExists(temporaryArchive); + fail("Unable to download test archive from " + ARCHIVE_URL + " (HTTP " + response.statusCode() + ")."); + } + + Files.move(temporaryArchive, ARCHIVE_PATH, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + private static URI createArchiveSourceUri() { + return URI.create("jar:" + ARCHIVE_PATH.toAbsolutePath().toUri() + "!/"); + } +} diff --git a/io.klib.app.p2.mirror.test/testdata/.gitignore b/io.klib.app.p2.mirror.test/testdata/.gitignore new file mode 100644 index 0000000..7c38a6b --- /dev/null +++ b/io.klib.app.p2.mirror.test/testdata/.gitignore @@ -0,0 +1,2 @@ +# downloaded test archive - do not commit +*.zip diff --git a/io.klib.app.p2.mirror/api.bnd b/io.klib.app.p2.mirror/api.bnd new file mode 100644 index 0000000..8c93614 --- /dev/null +++ b/io.klib.app.p2.mirror/api.bnd @@ -0,0 +1 @@ +Export-Package: io.klib.app.p2.mirror.api diff --git a/io.klib.app.p2.mirror/bnd.bnd b/io.klib.app.p2.mirror/bnd.bnd index f5694c8..d0c12f6 100644 --- a/io.klib.app.p2.mirror/bnd.bnd +++ b/io.klib.app.p2.mirror/bnd.bnd @@ -1,6 +1,6 @@ -sub *.bnd --buildpath.local: \ +-buildpath: \ org.eclipse.osgi,\ org.eclipse.osgi.services,\ org.eclipse.core.contenttype,\ @@ -35,6 +35,8 @@ org.eclipse.equinox.p2.transport.ecf,\ org.eclipse.equinox.p2.updatechecker,\ org.eclipse.equinox.p2.updatesite,\ + org.osgi.service.cm,\ + org.osgi.service.metatype.annotations,\ org.tukaani.xz -includeresource: \ diff --git a/io.klib.app.p2.mirror/config.gogo b/io.klib.app.p2.mirror/config.gogo new file mode 100644 index 0000000..3714f33 --- /dev/null +++ b/io.klib.app.p2.mirror/config.gogo @@ -0,0 +1,67 @@ +# Configure P2Mirror with Apache Felix Config Admin `cm:*` commands. +# +# Factory PID: +# io.klib.app.p2.mirror.service.P2Mirror +# +# This script shows the canonical Felix factory flow: +# cm:createFactoryConfiguration +# cm:getConfiguration +# cm:getFactoryConfiguration +# cm:listConfigurations +# and uses Gogo variables to capture the generated PID automatically. +# +# Adjust URIs to your local environment before running these commands. + +# 1) Create/get deterministic factory configuration and capture the Configuration object. +cfg = (cm:getFactoryConfiguration io.klib.app.p2.mirror.service.P2Mirror local) + +# 2) Read and print the generated PID from the Configuration object. +pid = ($cfg pid) +echo Using PID $pid + +# 3) Resolve that PID as a regular Configuration object. +cfg = (cm:getConfiguration $pid) + +# 4) Configure properties for that PID using your Felix CM property/update commands. +# Typical keys for this component: +# repoRootUri=file:/C:/p2mirror/repo +# agentUri=file:/C:/p2mirror/agent (optional; script uses component default) +# destinationName=local +# raw=true +# verbose=false +# downloadMetadata=true +# artifactCompressed=true +# metadataCompressed=true +# atomic=true +# latestVersionOnly=false +# strictDependenciesOnly=false +# followFilteredRequirementsOnly=false +# includeOptionalDependencies=false + +userDir = (property user.dir) +userDirUnix = ($userDir replaceAll "\\\\" "/") +rtRoot = ($userDirUnix concat "/generated/rt_gogo") +repoRoot = ($rtRoot concat "/repo") + +propsClass = ((bundle 0) loadclass java.util.Hashtable) +props = ($propsClass newInstance) +($props put repoRootUri $repoRoot) +($props put destinationName local) +($props put raw true) +($props put verbose false) +($props put downloadMetadata true) +($props put artifactCompressed true) +($props put metadataCompressed true) +($props put atomic true) +($props put latestVersionOnly false) +($props put strictDependenciesOnly false) +($props put followFilteredRequirementsOnly false) +($props put includeOptionalDependencies false) + +($cfg update $props) + +# 5) List created P2Mirror factory configurations. +cm:listConfigurations "(service.factoryPid=io.klib.app.p2.mirror.service.P2Mirror)" + +# 6) Validate that at least one service instance exists. +p2mirror:mirrorStatus diff --git a/io.klib.app.p2.mirror/gogo.bnd b/io.klib.app.p2.mirror/gogo.bnd new file mode 100644 index 0000000..6479a10 --- /dev/null +++ b/io.klib.app.p2.mirror/gogo.bnd @@ -0,0 +1 @@ +Private-Package: io.klib.app.p2.mirror.gogo diff --git a/io.klib.app.p2.mirror/launchMirror.bndrun b/io.klib.app.p2.mirror/launchMirror.bndrun index b1250be..856d681 100644 --- a/io.klib.app.p2.mirror/launchMirror.bndrun +++ b/io.klib.app.p2.mirror/launchMirror.bndrun @@ -69,19 +69,30 @@ bundles.p2.director: \ -runrequires: \ ${bundles.p2.director},\ - bnd.identity;id='io.klib.app.p2.mirror.service' + bnd.identity;id='io.klib.app.p2.mirror.api',\ + bnd.identity;id='io.klib.app.p2.mirror.gogo',\ + bnd.identity;id='io.klib.app.p2.mirror.service',\ + bnd.identity;id='org.apache.felix.gogo.command';version='[1.1.2,2.0.0)',\ + bnd.identity;id='org.apache.felix.gogo.shell';version='[1.1.4,2.0.0)',\ + bnd.identity;id='org.apache.felix.configadmin';version='[1.9.10,2.0.0)',\ + bnd.identity;id='org.apache.felix.configadmin' -runbundles: \ bcpg;version='[1.82.0,1.82.1)';startlevel=5,\ bcprov;version='[1.82.0,1.82.1)';startlevel=5,\ bcutil;version='[1.82.0,1.82.1)';startlevel=5,\ ch.qos.logback.core;version='[1.2.11,1.2.12)';startlevel=1,\ + io.klib.app.p2.mirror.api;version=snapshot;startlevel=6,\ + io.klib.app.p2.mirror.gogo;version=snapshot;startlevel=6,\ io.klib.app.p2.mirror.service;version=snapshot;startlevel=6,\ jcl.over.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ jul.to.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ log4j.over.slf4j;version='[2.0.17,2.0.18)';startlevel=1,\ org.apache.aries.spifly.dynamic.bundle;version='[1.3.7,1.3.8)';startlevel=2,\ org.apache.aries.spifly.dynamic.framework.extension;version='[1.2.1,1.2.2)';startlevel=1,\ + org.apache.felix.gogo.command;version='[1.1.2,1.1.3)';startlevel=5,\ + org.apache.felix.gogo.runtime;version='[1.1.6,1.1.7)';startlevel=5,\ + org.apache.felix.gogo.shell;version='[1.1.4,1.1.5)';startlevel=5,\ org.apache.felix.scr;version='[2.2.14,2.2.15)';startlevel=3,\ org.apache.httpcomponents.client5.httpclient5;version='[5.5.1,5.5.2)';startlevel=5,\ org.apache.httpcomponents.core5.httpcore5;version='[5.3.6,5.3.7)';startlevel=5,\ @@ -148,4 +159,5 @@ bundles.p2.director: \ org.sat4j.pb;version='[2.3.6,2.3.7)';startlevel=5,\ org.tukaani.xz;version='[1.11.0,1.11.1)';startlevel=5,\ slf4j.api;version='[2.0.17,2.0.18)';startlevel=5,\ - slf4j.osgi;version='[2.0.0,2.0.1)';startlevel=1 \ No newline at end of file + slf4j.osgi;version='[2.0.0,2.0.1)';startlevel=1,\ + org.apache.felix.configadmin;version='[1.9.10,1.9.11)';startlevel=5 \ No newline at end of file diff --git a/io.klib.app.p2.mirror/parallel-delete.gogo b/io.klib.app.p2.mirror/parallel-delete.gogo new file mode 100644 index 0000000..2e57035 --- /dev/null +++ b/io.klib.app.p2.mirror/parallel-delete.gogo @@ -0,0 +1,28 @@ +# Remove the two named parallel P2Mirror factory instances. +# +# Factory PID: +# io.klib.app.p2.mirror.service.P2Mirror +# +# Targets: +# local-a +# local-b + +# Instance A -------------------------------------------------------------- +cfgA = (cm:getFactoryConfiguration io.klib.app.p2.mirror.service.P2Mirror local-a) +pidA = ($cfgA pid) +echo Deleting instance A with PID $pidA +cm:getConfiguration $pidA +cm:delete + +# Instance B -------------------------------------------------------------- +cfgB = (cm:getFactoryConfiguration io.klib.app.p2.mirror.service.P2Mirror local-b) +pidB = ($cfgB pid) +echo Deleting instance B with PID $pidB +cm:getConfiguration $pidB +cm:delete + +# Verify no P2Mirror factory configurations remain for these instances. +cm:listConfigurations "(service.factoryPid=io.klib.app.p2.mirror.service.P2Mirror)" + +# Verify service instances are no longer available. +p2mirror:mirrorStatus diff --git a/io.klib.app.p2.mirror/parallel.gogo b/io.klib.app.p2.mirror/parallel.gogo new file mode 100644 index 0000000..77e17a7 --- /dev/null +++ b/io.klib.app.p2.mirror/parallel.gogo @@ -0,0 +1,65 @@ +# Configure two parallel P2Mirror factory instances with Apache Felix Config Admin `cm:*` commands. +# +# Factory PID: +# io.klib.app.p2.mirror.service.P2Mirror +# +# This script creates/updates two named instances: +# local-a +# local-b +# +# It uses Gogo variables to capture generated PIDs automatically. + +# Instance A -------------------------------------------------------------- +cfgA = (cm:getFactoryConfiguration io.klib.app.p2.mirror.service.P2Mirror local-a) +pidA = ($cfgA pid) +echo Configuring instance A with PID $pidA +cfgA = (cm:getConfiguration $pidA) +userDirA = (property user.dir) +userDirAUnix = ($userDirA replaceAll "\\\\" "/") +rootA = ($userDirAUnix concat "/generated/rt_gogo") +repoA = ($rootA concat "/repo-a") +propsClass = ((bundle 0) loadclass java.util.Hashtable) +propsA = ($propsClass newInstance) +($propsA put repoRootUri $repoA) +($propsA put destinationName local-a) +($propsA put raw true) +($propsA put verbose false) +($propsA put downloadMetadata true) +($propsA put artifactCompressed true) +($propsA put metadataCompressed true) +($propsA put atomic true) +($propsA put latestVersionOnly false) +($propsA put strictDependenciesOnly false) +($propsA put followFilteredRequirementsOnly false) +($propsA put includeOptionalDependencies false) +($cfgA update $propsA) + +# Instance B -------------------------------------------------------------- +cfgB = (cm:getFactoryConfiguration io.klib.app.p2.mirror.service.P2Mirror local-b) +pidB = ($cfgB pid) +echo Configuring instance B with PID $pidB +cfgB = (cm:getConfiguration $pidB) +userDirB = (property user.dir) +userDirBUnix = ($userDirB replaceAll "\\\\" "/") +rootB = ($userDirBUnix concat "/generated/rt_gogo") +repoB = ($rootB concat "/repo-b") +propsB = ($propsClass newInstance) +($propsB put repoRootUri $repoB) +($propsB put destinationName local-b) +($propsB put raw true) +($propsB put verbose false) +($propsB put downloadMetadata true) +($propsB put artifactCompressed true) +($propsB put metadataCompressed true) +($propsB put atomic true) +($propsB put latestVersionOnly false) +($propsB put strictDependenciesOnly false) +($propsB put followFilteredRequirementsOnly false) +($propsB put includeOptionalDependencies false) +($cfgB update $propsB) + +# Verify both factory configurations exist. +cm:listConfigurations "(service.factoryPid=io.klib.app.p2.mirror.service.P2Mirror)" + +# Verify service instances are available. +p2mirror:mirrorStatus diff --git a/io.klib.app.p2.mirror/service.bnd b/io.klib.app.p2.mirror/service.bnd index 74df9bd..43f78dd 100644 --- a/io.klib.app.p2.mirror/service.bnd +++ b/io.klib.app.p2.mirror/service.bnd @@ -8,5 +8,4 @@ Import-Package: \ !org.eclipse.equinox.internal.*,\ * - -Export-Package: io.klib.app.p2.mirror.service \ No newline at end of file +Private-Package: io.klib.app.p2.mirror.service \ No newline at end of file diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/P2Mirror.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/P2Mirror.java new file mode 100644 index 0000000..77e144d --- /dev/null +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/P2Mirror.java @@ -0,0 +1,8 @@ +package io.klib.app.p2.mirror.api; + +import java.net.URI; + +public interface P2Mirror { + + void mirror(URI repo) throws Exception; +} diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/package-info.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/package-info.java new file mode 100644 index 0000000..a683742 --- /dev/null +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/api/package-info.java @@ -0,0 +1,3 @@ +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("1.0.0") +package io.klib.app.p2.mirror.api; diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommand.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommand.java new file mode 100644 index 0000000..42d0322 --- /dev/null +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommand.java @@ -0,0 +1,53 @@ +package io.klib.app.p2.mirror.gogo; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; + +import io.klib.app.p2.mirror.api.P2Mirror; + +@Component( + service = Object.class, + property = { + "osgi.command.scope=p2mirror", + "osgi.command.function=mirrorRepo", + "osgi.command.function=mirrorStatus" + } +) +public class P2MirrorGogoCommand { + + static final String NO_SERVICE_INSTANCE_MESSAGE = "No P2Mirror service instance available. Configure a factory instance first."; + + private final List mirrors = new CopyOnWriteArrayList<>(); + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + void addMirror(P2Mirror mirror) { + mirrors.add(mirror); + } + + void removeMirror(P2Mirror mirror) { + mirrors.remove(mirror); + } + + public void mirrorRepo(URI sourceURI) throws Exception { + if (sourceURI == null) { + throw new IllegalArgumentException("sourceURI must not be null"); + } + if (mirrors.isEmpty()) { + throw new IllegalStateException(NO_SERVICE_INSTANCE_MESSAGE); + } + mirrors.get(0).mirror(sourceURI); + } + + public String mirrorStatus() { + if (mirrors.isEmpty()) { + return NO_SERVICE_INSTANCE_MESSAGE; + } + return "P2Mirror service instances available: " + mirrors.size(); + } +} diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2Mirror.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2Mirror.java index 0632f0f..8b361b9 100644 --- a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2Mirror.java +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2Mirror.java @@ -1,6 +1,6 @@ package io.klib.app.p2.mirror.service; -import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; @@ -10,6 +10,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Duration; +import java.util.Objects; import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.equinox.p2.core.IProvisioningAgent; @@ -17,140 +18,112 @@ import org.eclipse.equinox.p2.internal.repository.tools.MirrorApplication; import org.eclipse.equinox.p2.internal.repository.tools.RepositoryDescriptor; import org.eclipse.equinox.p2.internal.repository.tools.SlicingOptions; -import org.eclipse.equinox.p2.repository.artifact.IArtifactRepositoryManager; -import org.eclipse.equinox.p2.repository.metadata.IMetadataRepositoryManager; import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.Designate; -@Component(immediate = true) -public class P2Mirror { +@Component(service = io.klib.app.p2.mirror.api.P2Mirror.class) +@Designate(ocd = P2MirrorConfig.class, factory = true) +public class P2Mirror implements io.klib.app.p2.mirror.api.P2Mirror { + private static final String USER_DIR = System.getProperty("user.dir").replace("\\", "/"); + private static final String LOCAL_ROOT_URI = USER_DIR + "/repo"; + private static final String LOCAL_AGENT_URI = "file:/" + USER_DIR + "/p2"; @Reference private IProvisioningAgentProvider agentProvider; - private IProvisioningAgent agent; - @SuppressWarnings("unused") - private IArtifactRepositoryManager aRepoMgr; - @SuppressWarnings("unused") - private IMetadataRepositoryManager mRepoMgr; - - private static final Duration SERVICE_LOOKUP_TIMEOUT = Duration.ofSeconds(5); + private volatile IProvisioningAgent agent; + private volatile ServiceRegistration registration; + private volatile P2MirrorConfig config; + + private static final Duration SERVICE_LOOKUP_TIMEOUT = Duration.ofSeconds(30); private static final Duration SERVICE_LOOKUP_RETRY_DELAY = Duration.ofMillis(250); - @SuppressWarnings("unused") - private static final String FEATURE_GROUP = ".feature.group"; - - private static final String USER_DIR = System.getProperty("user.dir").replace("\\", "/"); - private static final String LOCAL_ROOT_URI = USER_DIR + "/repo"; - - private String url = "https://bndtools.jfrog.io/artifactory/rel_7.2.1/"; - private String suffix = url; - private String localUri; + private static final String[] METADATA_FILES = new String[] { + "p2.index", "content.xml.xz", "content.jar", "artifacts.xml.xz", "artifacts.jar" + }; - public void activate(BundleContext ctx) throws Exception { - System.out.println("started"); + @Activate + void activate(BundleContext context, P2MirrorConfig config) throws Exception { + this.config = Objects.requireNonNull(config, "config"); + this.agent = agentProvider.createAgent(resolveAgentLocation(config)); + this.registration = context.registerService(IProvisioningAgent.class, agent, null); + + awaitAgentServiceSafely("org.eclipse.equinox.p2.repository.metadataRepositoryManager", Object.class); + awaitAgentServiceSafely("org.eclipse.equinox.p2.repository.artifactRepositoryManager", Object.class); + } + + @Deactivate + void deactivate() { + ServiceRegistration localRegistration = registration; + registration = null; + if (localRegistration != null) { + localRegistration.unregister(); + } - agent = agentProvider.createAgent(new URI("file:/" + USER_DIR + "/p2")); - ctx.registerService(IProvisioningAgent.class, agent, null); + IProvisioningAgent localAgent = agent; + agent = null; + if (localAgent != null) { + localAgent.stop(); + } + } - mRepoMgr = getMetadataRepositoryManager(); - aRepoMgr = getArtifactRepositoryManager(); + @Override + public void mirror(URI repo) throws Exception { + if (repo == null) { + throw new IllegalArgumentException("repo must not be null"); + } + P2MirrorConfig localConfig = Objects.requireNonNull(config, "config"); MirrorApplication mirrorApplication = new MirrorApplication(); - RepositoryDescriptor srcRepoDesc = new RepositoryDescriptor(); - if (url.startsWith("file:")) { - suffix = url.toString().replaceFirst("file:", "").replaceFirst(":", "_"); - } else { - suffix = url.toString().replaceFirst(".*?:", "").replaceAll("//", "/"); - } - if (url.toString().endsWith("!/")) { - suffix = suffix.toString().replaceAll(".jar!/", "_jar").replaceFirst(".*/", ""); - } - - srcRepoDesc.setLocation(new URI(url)); - mirrorApplication.addSource(srcRepoDesc); - - localUri = LOCAL_ROOT_URI + suffix; - File targetLocalStorage = new File(localUri); - targetLocalStorage.mkdirs(); - String name = "x"; - // create metadata repository - RepositoryDescriptor destMetadataRepoDesc = createTargetRepoDesc(targetLocalStorage, name, - RepositoryDescriptor.KIND_METADATA); - mirrorApplication.addDestination(destMetadataRepoDesc); - - // create artifact repository - RepositoryDescriptor destArtifactRepoDesc = createTargetRepoDesc(targetLocalStorage, name, - RepositoryDescriptor.KIND_ARTIFACT); - destArtifactRepoDesc.setCompressed(true); - destArtifactRepoDesc.setFormat(new URI("file:///Z:/ENGINE_LIB_DIR/cec/p2_repo_packedSiblings")); - mirrorApplication.addDestination(destArtifactRepoDesc); - mirrorApplication.setRaw(true); - mirrorApplication.setVerbose(true); - - SlicingOptions sliceOpts = new SlicingOptions(); -// sliceOpts.latestVersionOnly(true); -// sliceOpts.considerStrictDependencyOnly(false); -// sliceOpts.followOnlyFilteredRequirements(false); -// sliceOpts.includeOptionalDependencies(false); -// sliceOpts.latestVersionOnly(false); - mirrorApplication.setSlicingOptions(sliceOpts); - - /* - * List ius = new ArrayList(); - * InstallableUnit iu = new InstallableUnit(); - * iu.setId("org.eclipse.nebula.widgets.paperclips.feature"+FEATURE_GROUP); - * iu.setVersion(Version.create("0.0.0")); ius.add(iu); - * - * iu = new InstallableUnit(); - * iu.setId("org.eclipse.nebula.paperclips.widgets"); - * iu.setVersion(Version.create("0.0.0")); ius.add(iu); - * - * mirrorApplication.setSourceIUs(ius); - */ - mirrorApplication.run(new NullProgressMonitor()); - - downloadMetadata(); + RepositoryDescriptor sourceDescriptor = new RepositoryDescriptor(); + sourceDescriptor.setLocation(repo); + mirrorApplication.addSource(sourceDescriptor); - System.out.println("finished"); - } + String suffix = suffixFromSource(repo.toString()); + Path targetRoot = resolveRepoRootPath(localConfig); + Path targetDirectory = targetRoot.resolve(suffix).normalize(); + Files.createDirectories(targetDirectory); - private RepositoryDescriptor createTargetRepoDesc(final File targetLocation, final String name, final String kind) { - RepositoryDescriptor destRepoDesc = new RepositoryDescriptor(); - destRepoDesc.setKind(kind); - destRepoDesc.setCompressed(true); - destRepoDesc.setLocation(targetLocation.toURI()); - destRepoDesc.setName(name); - destRepoDesc.setAtomic("true"); // what is this for?^ - // destination.setFormat(sourceLocation); // can be used to define a target - // format based on existing repository - return destRepoDesc; - } + RepositoryDescriptor metadataDestination = createTargetRepoDesc(targetDirectory, localConfig.destinationName(), + RepositoryDescriptor.KIND_METADATA, localConfig.metadataCompressed(), localConfig.atomic()); + mirrorApplication.addDestination(metadataDestination); - public IMetadataRepositoryManager getMetadataRepositoryManager() { - IMetadataRepositoryManager repoMgr = awaitAgentService(IMetadataRepositoryManager.SERVICE_NAME, - IMetadataRepositoryManager.class); + RepositoryDescriptor artifactDestination = createTargetRepoDesc(targetDirectory, localConfig.destinationName(), + RepositoryDescriptor.KIND_ARTIFACT, localConfig.artifactCompressed(), localConfig.atomic()); + mirrorApplication.addDestination(artifactDestination); - if (repoMgr == null) { - throw new IllegalStateException("Provisioning agent service unavailable: " - + IMetadataRepositoryManager.SERVICE_NAME + " after " + SERVICE_LOOKUP_TIMEOUT.toSeconds() + "s"); - } + mirrorApplication.setRaw(localConfig.raw()); + mirrorApplication.setVerbose(localConfig.verbose()); - return repoMgr; - } + SlicingOptions slicingOptions = new SlicingOptions(); + slicingOptions.latestVersionOnly(localConfig.latestVersionOnly()); + slicingOptions.considerStrictDependencyOnly(localConfig.strictDependenciesOnly()); + slicingOptions.followOnlyFilteredRequirements(localConfig.followFilteredRequirementsOnly()); + slicingOptions.includeOptionalDependencies(localConfig.includeOptionalDependencies()); + mirrorApplication.setSlicingOptions(slicingOptions); - public IArtifactRepositoryManager getArtifactRepositoryManager() { - IArtifactRepositoryManager repoMgr = awaitAgentService(IArtifactRepositoryManager.SERVICE_NAME, - IArtifactRepositoryManager.class); + mirrorApplication.run(new NullProgressMonitor()); - if (repoMgr == null) { - throw new IllegalStateException("Provisioning agent service unavailable: " - + IArtifactRepositoryManager.SERVICE_NAME + " after " + SERVICE_LOOKUP_TIMEOUT.toSeconds() + "s"); + if (localConfig.downloadMetadata()) { + downloadMetadata(repo, targetDirectory); } + } - return repoMgr; + private RepositoryDescriptor createTargetRepoDesc(Path targetLocation, String name, String kind, boolean compressed, + boolean atomic) { + RepositoryDescriptor destination = new RepositoryDescriptor(); + destination.setKind(kind); + destination.setCompressed(compressed); + destination.setLocation(targetLocation.toUri()); + destination.setName(name); + destination.setAtomic(Boolean.toString(atomic)); + return destination; } @SuppressWarnings("unchecked") @@ -170,10 +143,11 @@ private T awaitAgentService(String serviceName, Class serviceType) { } if (service == null) { - return null; + throw new IllegalStateException("Provisioning agent service unavailable: " + serviceName + " after " + + SERVICE_LOOKUP_TIMEOUT.toSeconds() + "s"); } - if (!serviceType.isInstance(service)) { + if (!serviceType.isAssignableFrom(service.getClass())) { throw new IllegalStateException("Provisioning agent service '" + serviceName + "' is not of type " + serviceType.getName() + ": " + service.getClass().getName()); } @@ -181,38 +155,136 @@ private T awaitAgentService(String serviceName, Class serviceType) { return (T) service; } - private void downloadMetadata() { - String[] files = new String[] { "p2.index", "content.xml.xz", "content.jar", "artifacts.xml.xz", - "artifacts.jar" }; - + private void awaitAgentServiceSafely(String serviceName, Class serviceType) { try { - for (int i = 0; i < files.length; i++) { - String fileUrl = url + files[i]; - URL url = new URI(fileUrl).toURL(); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("HEAD"); - - int responseCode = connection.getResponseCode(); - if (responseCode == HttpURLConnection.HTTP_OK) { - // File exists, download it - String fileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1); - String filePath = localUri + fileName; - - Path destination = Path.of(filePath); - Files.copy(url.openStream(), destination, StandardCopyOption.REPLACE_EXISTING); - - System.out.println("File downloaded successfully: " + filePath); - } else { - // File does not exist - System.out.println("File not found: " + fileUrl); + awaitAgentService(serviceName, serviceType); + } catch (IllegalStateException exception) { + System.err.println("P2Mirror activation continuing without ready agent service " + serviceName + ": " + + exception.getMessage()); + } + } + + private void downloadMetadata(URI sourceRepo, Path targetDirectory) throws IOException, URISyntaxException { + for (String fileName : METADATA_FILES) { + URI fileUri = resolveRepositoryFile(sourceRepo, fileName); + Path destination = targetDirectory.resolve(fileName); + if ("file".equalsIgnoreCase(fileUri.getScheme())) { + Path source = Path.of(fileUri); + if (Files.exists(source)) { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); } + continue; + } + + if ("jar".equalsIgnoreCase(fileUri.getScheme())) { + copyJarEntryIfPresent(fileUri, destination); + continue; + } + + if (isHttpUri(fileUri) && existsHttp(fileUri)) { + URL url = fileUri.toURL(); + Files.copy(url.openStream(), destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private URI resolveRepositoryFile(URI sourceRepo, String fileName) { + if ("jar".equalsIgnoreCase(sourceRepo.getScheme())) { + return URI.create(sourceRepo.toString() + fileName); + } + return sourceRepo.resolve(fileName); + } + + private void copyJarEntryIfPresent(URI fileUri, Path destination) throws IOException { + try (var inputStream = fileUri.toURL().openStream()) { + Files.copy(inputStream, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (FileNotFoundException exception) { + // Optional metadata file not present in the source archive. + } + } + + private boolean isHttpUri(URI fileUri) { + String scheme = fileUri.getScheme(); + return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); + } + + private boolean existsHttp(URI fileUri) throws IOException { + HttpURLConnection connection = (HttpURLConnection) fileUri.toURL().openConnection(); + try { + connection.setRequestMethod("HEAD"); + int responseCode = connection.getResponseCode(); + return responseCode == HttpURLConnection.HTTP_OK; + } finally { + connection.disconnect(); + } + } + + private String suffixFromSource(String url) { + String suffix; + if (url.startsWith("file:")) { + suffix = url.replaceFirst("file:", "").replaceFirst(":", "_"); + } else { + suffix = url.replaceFirst(".*?:", "").replaceAll("//", "/"); + } + while (suffix.startsWith("/")) { + suffix = suffix.substring(1); + } + if (url.endsWith("!/")) { + suffix = suffix.replaceAll(".jar!/", "_jar").replaceFirst(".*/", ""); + } + return suffix; + } + + private String resolveRepoRootUri(P2MirrorConfig config) { + if (P2MirrorConfig.LOCAL_ROOT_URI.equals(config.repoRootUri())) { + return LOCAL_ROOT_URI; + } + return config.repoRootUri(); + } + + private Path resolveRepoRootPath(P2MirrorConfig config) { + return resolvePath(resolveRepoRootUri(config)); + } + + private String resolveAgentUri(P2MirrorConfig config) { + if (P2MirrorConfig.AGENT_URI.equals(config.agentUri())) { + return LOCAL_AGENT_URI; + } + return config.agentUri(); + } + + private URI resolveAgentLocation(P2MirrorConfig config) throws URISyntaxException { + String agentUri = resolveAgentUri(config); + if (looksLikeWindowsPath(agentUri)) { + return Path.of(agentUri).toUri(); + } + + URI uri = new URI(agentUri); + if (uri.getScheme() != null) { + return uri; + } + + return Path.of(agentUri).toUri(); + } + + private Path resolvePath(String value) { + if (looksLikeWindowsPath(value)) { + return Path.of(value); + } + + try { + URI uri = URI.create(value); + if (uri.getScheme() != null) { + return Path.of(uri); } - } catch (IOException | URISyntaxException e) { - e.printStackTrace(); + } catch (IllegalArgumentException exception) { + // Fall back to treating the value as a filesystem path. } + + return Path.of(value); } - public void addMeth() { - System.out.println("hallo"); + private boolean looksLikeWindowsPath(String value) { + return value != null && value.matches("^[a-zA-Z]:[\\\\/].*"); } } diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2MirrorConfig.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2MirrorConfig.java new file mode 100644 index 0000000..eeac222 --- /dev/null +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/P2MirrorConfig.java @@ -0,0 +1,92 @@ +package io.klib.app.p2.mirror.service; + +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +@ObjectClassDefinition( + name = "P2 Mirror Service Configuration", + description = "Factory configuration for spawning p2 mirror service instances." +) +public @interface P2MirrorConfig { + + String LOCAL_ROOT_URI = "LOCAL_ROOT_URI"; + String AGENT_URI = "LOCAL_AGENT_URI"; + + @AttributeDefinition( + name = "Mirror Root Folder URI", + description = "Root folder where mirrored repositories are written." + ) + String repoRootUri() default LOCAL_ROOT_URI; + + @AttributeDefinition( + name = "Provisioning Agent URI", + description = "Location for Equinox p2 provisioning agent data used by this instance." + ) + String agentUri() default AGENT_URI; + + @AttributeDefinition( + name = "Destination Name", + description = "Repository name used for created metadata and artifact destinations." + ) + String destinationName() default "mirror"; + + @AttributeDefinition( + name = "Raw Mirror", + description = "Enable raw mirroring mode in the p2 mirror application." + ) + boolean raw() default true; + + @AttributeDefinition( + name = "Verbose Logging", + description = "Enable verbose output from the p2 mirror application." + ) + boolean verbose() default true; + + @AttributeDefinition( + name = "Download Metadata Files", + description = "Copy p2 metadata files like p2.index and content/artifacts descriptors after mirror run." + ) + boolean downloadMetadata() default true; + + @AttributeDefinition( + name = "Artifact Destination Compressed", + description = "Compress artifact destination repository metadata." + ) + boolean artifactCompressed() default true; + + @AttributeDefinition( + name = "Metadata Destination Compressed", + description = "Compress metadata destination repository metadata." + ) + boolean metadataCompressed() default true; + + @AttributeDefinition( + name = "Atomic Writes", + description = "Use atomic repository update semantics where supported." + ) + boolean atomic() default true; + + @AttributeDefinition( + name = "Latest Version Only", + description = "Mirror only the latest IU versions." + ) + boolean latestVersionOnly() default false; + + @AttributeDefinition( + name = "Strict Dependencies Only", + description = "Consider only strict dependencies during slicing." + ) + boolean strictDependenciesOnly() default false; + + @AttributeDefinition( + name = "Follow Filtered Requirements Only", + description = "Follow only filtered requirements during slicing." + ) + boolean followFilteredRequirementsOnly() default false; + + @AttributeDefinition( + name = "Include Optional Dependencies", + description = "Include optional dependencies during slicing." + ) + boolean includeOptionalDependencies() default false; +} diff --git a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/package-info.java b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/package-info.java index dbea6bf..ba61c72 100644 --- a/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/package-info.java +++ b/io.klib.app.p2.mirror/src/io/klib/app/p2/mirror/service/package-info.java @@ -1,4 +1,3 @@ -@org.osgi.annotation.bundle.Export @org.osgi.annotation.versioning.Version("1.0.0") package io.klib.app.p2.mirror.service; diff --git a/io.klib.app.p2.mirror/test/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommandTest.java b/io.klib.app.p2.mirror/test/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommandTest.java new file mode 100644 index 0000000..bdb96b4 --- /dev/null +++ b/io.klib.app.p2.mirror/test/io/klib/app/p2/mirror/gogo/P2MirrorGogoCommandTest.java @@ -0,0 +1,40 @@ +package io.klib.app.p2.mirror.gogo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import io.klib.app.p2.mirror.api.P2Mirror; + +class P2MirrorGogoCommandTest { + + @Test + void mirrorStatusWithoutServiceInstanceShowsGuidance() { + P2MirrorGogoCommand command = new P2MirrorGogoCommand(); + + assertEquals(P2MirrorGogoCommand.NO_SERVICE_INSTANCE_MESSAGE, command.mirrorStatus()); + } + + @Test + void mirrorStatusWithServiceInstanceShowsCount() { + P2MirrorGogoCommand command = new P2MirrorGogoCommand(); + P2Mirror mirror = repo -> { + }; + command.addMirror(mirror); + + assertEquals("P2Mirror service instances available: 1", command.mirrorStatus()); + } + + @Test + void mirrorRepoWithoutServiceInstanceThrowsGuidanceMessage() { + P2MirrorGogoCommand command = new P2MirrorGogoCommand(); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> command.mirrorRepo(URI.create("https://example.com/repository"))); + + assertEquals(P2MirrorGogoCommand.NO_SERVICE_INSTANCE_MESSAGE, error.getMessage()); + } +}