diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml new file mode 100644 index 00000000..94646c05 --- /dev/null +++ b/.github/actions/setup-env/action.yml @@ -0,0 +1,28 @@ +name: Setup environment +description: Checkout, optionally authenticate to GCP, and set up GraalVM + +inputs: + gcp_workload_identity_provider: + required: false + gcp_service_account: + required: false + github_token: + required: true + +runs: + using: composite + steps: + - name: Authenticate to Google Cloud + if: inputs.gcp_workload_identity_provider != '' + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ inputs.gcp_workload_identity_provider }} + service_account: ${{ inputs.gcp_service_account }} + + - name: Set up JDK + uses: graalvm/setup-graalvm@v1 + with: + java-version: '25.0.1' + distribution: 'graalvm-community' + cache: 'maven' + github-token: ${{ inputs.github_token }} diff --git a/.github/jreleaser/jreleaser-preview.yml b/.github/jreleaser/jreleaser-preview.yml new file mode 100644 index 00000000..3c0f3840 --- /dev/null +++ b/.github/jreleaser/jreleaser-preview.yml @@ -0,0 +1,96 @@ +project: + name: streamx + description: StreamX CLI + versionPattern: SEMVER + license: LicenseRef-StreamX + licenseUrl: https://github.com/streamx-com/streamx-cli/blob/main/LICENSE.txt + links: + homepage: https://github.com/streamx-com/streamx-cli + authors: + - streamx + inceptionYear: '2026' + +platform: + replacements: + osx-x86_64: macos-x86_64 + osx-aarch_64: macos-aarch64 + +assemble: + javaArchive: + streamx-jar: + active: ALWAYS + exported: true + stereotype: CLI + archiveName: streamx-jar-{{projectVersion}} + executable: + name: streamx + windowsExtension: bat + mainJar: + path: artifacts/streamx-jar.jar + fileSets: + - input: . + includes: + - LICENSE.txt + +distributions: + streamx: + type: BINARY + executable: + name: streamx + artifacts: + - path: artifacts/streamx-{{projectVersion}}-linux-x86_64.zip + platform: linux-x86_64 + - path: artifacts/streamx-{{projectVersion}}-linux-aarch64.zip + platform: linux-aarch_64 + - path: artifacts/streamx-{{projectVersion}}-macos-aarch64.zip + platform: osx-aarch_64 + - path: artifacts/streamx-{{projectVersion}}-macos-x86_64.zip + platform: osx-x86_64 + + streamx-jar: + type: JAVA_BINARY + executable: + name: streamx + brew: + active: NEVER + +release: + github: + owner: streamx-com + name: streamx-cli-preview + overwrite: true + tagName: '{{projectVersion}}' + commitAuthor: + name: "streamx-cli-release-bot[bot]" + email: "264353404+streamx-cli-release-bot[bot]@users.noreply.github.com" + prerelease: + enabled: true + milestone: + close: false + changelog: + formatted: ALWAYS + format: '- {{commitShortHash}} {{commitTitle}}' + contributors: + format: '- {{contributorName}} ({{contributorUsernameAsLink}})' + enabled: true + +packagers: + brew: + active: ALWAYS + commitAuthor: + name: "streamx-cli-release-bot[bot]" + email: "264353404+streamx-cli-release-bot[bot]@users.noreply.github.com" + repository: + owner: streamx-com + name: homebrew-preview-tap + branch: main + branchPush: main + formulaName: streamx + multiPlatform: true + +files: + artifacts: + - path: artifacts/streamx-linux-x86_64 + - path: artifacts/streamx-linux-aarch64 + - path: artifacts/streamx-macos-aarch64 + - path: artifacts/streamx-macos-x86_64 \ No newline at end of file diff --git a/.github/jreleaser/jreleaser.yml b/.github/jreleaser/jreleaser.yml new file mode 100644 index 00000000..1a5de44d --- /dev/null +++ b/.github/jreleaser/jreleaser.yml @@ -0,0 +1,92 @@ +project: + name: streamx + description: StreamX CLI + versionPattern: SEMVER + license: LicenseRef-StreamX + licenseUrl: https://github.com/streamx-com/streamx-cli/blob/main/LICENSE.txt + links: + homepage: https://github.com/streamx-com/streamx-cli + authors: + - streamx + inceptionYear: '2026' + +platform: + replacements: + osx-x86_64: macos-x86_64 + osx-aarch_64: macos-aarch64 + +assemble: + javaArchive: + streamx-jar: + active: ALWAYS + exported: true + stereotype: CLI + archiveName: streamx-jar-{{projectVersion}} + executable: + name: streamx + windowsExtension: bat + mainJar: + path: artifacts/streamx-jar.jar + fileSets: + - input: . + includes: + - LICENSE.txt + +distributions: + streamx: + type: BINARY + executable: + name: streamx + artifacts: + - path: artifacts/streamx-{{projectVersion}}-linux-x86_64.zip + platform: linux-x86_64 + - path: artifacts/streamx-{{projectVersion}}-linux-aarch64.zip + platform: linux-aarch_64 + - path: artifacts/streamx-{{projectVersion}}-macos-aarch64.zip + platform: osx-aarch_64 + - path: artifacts/streamx-{{projectVersion}}-macos-x86_64.zip + platform: osx-x86_64 + + streamx-jar: + type: JAVA_BINARY + executable: + name: streamx + brew: + active: NEVER + +release: + github: + overwrite: true + tagName: '{{projectVersion}}' + commitAuthor: + name: "streamx-cli-release-bot[bot]" + email: "264353404+streamx-cli-release-bot[bot]@users.noreply.github.com" + milestone: + close: false + changelog: + formatted: ALWAYS + format: '- {{commitShortHash}} {{commitTitle}}' + contributors: + format: '- {{contributorName}} ({{contributorUsernameAsLink}})' + enabled: true + +packagers: + brew: + active: ALWAYS + commitAuthor: + name: "streamx-cli-release-bot[bot]" + email: "264353404+streamx-cli-release-bot[bot]@users.noreply.github.com" + repository: + owner: streamx-com + name: homebrew-tap + branch: main + branchPush: main + formulaName: streamx + multiPlatform: true + +files: + artifacts: + - path: artifacts/streamx-linux-x86_64 + - path: artifacts/streamx-linux-aarch64 + - path: artifacts/streamx-macos-aarch64 + - path: artifacts/streamx-macos-x86_64 \ No newline at end of file diff --git a/.github/scripts/with-capture-docker-logs.sh b/.github/scripts/with-capture-docker-logs.sh new file mode 100755 index 00000000..7482bf71 --- /dev/null +++ b/.github/scripts/with-capture-docker-logs.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v docker &> /dev/null; then + echo "Docker not found, skipping docker log collection." + exec "$@" +fi + +LOGS_DIR="${DOCKER_LOGS_DIR:-target/docker-logs}" +mkdir -p "$LOGS_DIR" + +EVENTS_LOG="${LOGS_DIR}/docker-events.log" + +log_event() { + echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*" | tee -a "$EVENTS_LOG" +} + +# Named pipes so we can capture PIDs of both sides of each pipeline +IMAGE_FIFO=$(mktemp -u) +CONTAINER_FIFO=$(mktemp -u) +mkfifo "$IMAGE_FIFO" "$CONTAINER_FIFO" + +# --- Image pull logging --- +docker events \ + --filter 'type=image' \ + --format '{{.Time}} IMAGE {{.Action}} {{.Actor.Attributes.name}}' \ + > "$IMAGE_FIFO" & +IMAGE_EVENTS_PID=$! + +while read -r line; do + log_event "$line" +done < "$IMAGE_FIFO" & +IMAGE_READER_PID=$! + +# --- Container lifecycle logging --- +docker events \ + --filter 'type=container' \ + --format '{{.Time}} CONTAINER {{.Action}} name={{.Actor.Attributes.name}} id={{.ID}} image={{.Actor.Attributes.image}}' \ + > "$CONTAINER_FIFO" & +CONTAINER_EVENTS_PID=$! + +while read -r event; do + log_event "$event" + + action=$(echo "$event" | awk '{print $3}') + name=$(echo "$event" | awk '{print $4}' | cut -d= -f2) + id=$(echo "$event" | awk '{print $5}' | cut -d= -f2) + short_id="${id:0:12}" + logfile="${LOGS_DIR}/${name}-${short_id}.log" + + case "$action" in + create) + log_event "Container created: $name ($short_id) — capturing inspect snapshot" + { + echo "=== docker inspect at CREATE ($(date -u '+%Y-%m-%dT%H:%M:%SZ')) ===" + docker inspect "$id" 2>&1 + echo "" + } >> "$logfile" + ;; + start) + log_event "Container started: $name ($short_id) — following logs" + { + echo "=== docker inspect at START ($(date -u '+%Y-%m-%dT%H:%M:%SZ')) ===" + docker inspect "$id" 2>&1 + echo "" + echo "=== Container logs ===" + } >> "$logfile" + docker logs -f "$id" >> "$logfile" 2>&1 & + ;; + die|stop|kill|oom) + log_event "Container stopped ($action): $name ($short_id) — capturing exit state" + { + echo "=== docker inspect at ${action^^} ($(date -u '+%Y-%m-%dT%H:%M:%SZ')) ===" + docker inspect "$id" 2>&1 || true + echo "" + } >> "$logfile" + ;; + esac +done < "$CONTAINER_FIFO" & +CONTAINER_READER_PID=$! + +cleanup() { + log_event "Shutting down docker log collector" + kill "$IMAGE_EVENTS_PID" "$IMAGE_READER_PID" \ + "$CONTAINER_EVENTS_PID" "$CONTAINER_READER_PID" 2>/dev/null || true + pkill -f "docker logs -f" 2>/dev/null || true + rm -f "$IMAGE_FIFO" "$CONTAINER_FIFO" + wait "$IMAGE_EVENTS_PID" "$IMAGE_READER_PID" \ + "$CONTAINER_EVENTS_PID" "$CONTAINER_READER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +log_event "Docker log collector started (PID $$), writing to $LOGS_DIR" + +"$@" \ No newline at end of file diff --git a/.github/workflows/build-cross-platform.yml b/.github/workflows/build-cross-platform.yml new file mode 100644 index 00000000..192f2438 --- /dev/null +++ b/.github/workflows/build-cross-platform.yml @@ -0,0 +1,142 @@ +name: Build +run-name: Build ${{ github.ref_name }} - ${{ inputs.ref || github.sha }} + +on: + push: + branches: + - main + + # TODO - remove before merge + - native-build-2 + workflow_call: + inputs: + ref: + required: false + type: string + outputs: + platforms: + value: ${{ jobs.setup.outputs.platforms }} + +permissions: + id-token: write + checks: write + contents: read + pull-requests: read + +jobs: + setup: + runs-on: ubuntu-24.04 + name: setup + outputs: + platforms: ${{ steps.matrix.outputs.platforms }} + steps: + - name: Define platform matrix + id: matrix + run: | + PLATFORMS='[ + { "os": "ubuntu-24.04", "name": "linux-amd64", "artifact": "streamx-linux-x86_64" }, + { "os": "ubuntu-24.04-arm", "name": "linux-arm64", "artifact": "streamx-linux-aarch64" }, + { "os": "macos-26-xlarge", "name": "macos-arm64", "artifact": "streamx-macos-aarch64" }, + { "os": "macos-26-large", "name": "macos-amd64", "artifact": "streamx-macos-x86_64" } + ]' + echo "platforms=$(echo "$PLATFORMS" | jq -c .)" >> "$GITHUB_OUTPUT" + + build-jar: + needs: setup + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: ./.github/actions/setup-env + with: + gcp_workload_identity_provider: ${{ secrets.GCP_STREAMX_RELEASES_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ secrets.GCP_STREAMX_RELEASES_RELEASE_DEPLOY_SA }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run tests with native-image-agent + run: | + .github/scripts/with-capture-docker-logs.sh ./mvnw verify -Pci + + - name: Upload Docker logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-logs-${{ github.job }} + path: target/docker-logs/ + if-no-files-found: ignore + + - name: Upload test reports + if: always() + uses: scacap/action-surefire-report@v1 + with: + check_name: Test report - build-jar + + - name: Upload reachability metadata + uses: actions/upload-artifact@v4 + with: + name: reachability-metadata + path: src/main/resources/META-INF/native-image/ + + - name: Upload runner JAR + uses: actions/upload-artifact@v4 + with: + name: streamx-runner-jar + path: target/*-runner.jar + + build-native: + needs: [setup, build-jar] + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.setup.outputs.platforms) }} + runs-on: ${{ matrix.os }} + name: ${{ matrix.name }}-build + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: ./.github/actions/setup-env + with: + gcp_workload_identity_provider: ${{ secrets.GCP_STREAMX_RELEASES_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ secrets.GCP_STREAMX_RELEASES_RELEASE_DEPLOY_SA }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download reachability metadata + uses: actions/download-artifact@v4 + with: + name: reachability-metadata + path: src/main/resources/META-INF/native-image/ + + - name: Build native-image executable + run: | + .github/scripts/with-capture-docker-logs.sh ./mvnw verify -Pci,native + + - name: Upload test reports + if: always() + uses: scacap/action-surefire-report@v1 + with: + check_name: Test report - build-native ${{ matrix.name }} + + - name: Upload Docker logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-logs-${{ matrix.name }} + path: target/docker-logs/ + if-no-files-found: ignore + + - name: Prepare artifact + run: | + mkdir -p staging + cp target/*-runner staging/${{ matrix.artifact }} + chmod +x staging/${{ matrix.artifact }} + + - name: Smoke test + run: ./staging/${{ matrix.artifact }} --help + + - name: Upload native executable + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: staging/${{ matrix.artifact }} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..07123510 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: PR Build +run-name: PR Build - ${{ github.event.pull_request.title }} + +on: + pull_request: + +permissions: + id-token: write + contents: read + checks: write + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-env + with: + gcp_workload_identity_provider: ${{ secrets.GCP_STREAMX_RELEASES_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ secrets.GCP_STREAMX_RELEASES_RELEASE_DEPLOY_SA }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Test and build JAR + run: | + .github/scripts/with-capture-docker-logs.sh ./mvnw verify -Pci + + - name: Upload test reports + if: always() + uses: scacap/action-surefire-report@v1 + with: + check_name: Test report - build + + - name: Upload Docker logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: Docker logs - build + path: target/docker-logs/ + if-no-files-found: ignore \ No newline at end of file diff --git a/.github/workflows/ci-test-build.yml b/.github/workflows/ci-test-build.yml deleted file mode 100644 index a0b84d70..00000000 --- a/.github/workflows/ci-test-build.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: 'Test: CI build' - -on: - pull_request: - push: - branches: - - main - -permissions: - id-token: write - contents: read - -jobs: - linux-test-build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - id: 'auth' - name: 'Authenticate to Google Cloud' - uses: 'google-github-actions/auth@v2' - with: - workload_identity_provider: ${{ secrets.GCP_STREAMX_RELEASES_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_STREAMX_RELEASES_RELEASE_DEPLOY_SA }} - - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - cache: 'maven' - - - name: Run tests - run: | - ./mvnw clean verify - windows-test-build: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up JDK 21 - uses: actions/setup-java@v3 - with: - java-version: '21' - distribution: 'temurin' - cache: 'maven' - - - name: Run tests - run: | - ./mvnw clean verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9aaf2209 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,274 @@ +name: Release +run-name: ${{ (github.event_name == 'workflow_dispatch' && inputs.preview) && 'Preview Release' || 'Release' }} (${{ github.ref_name }} ${{ github.sha }}) + +on: + workflow_dispatch: + inputs: + preview: + description: 'Preview release' + type: boolean + default: false + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + id-token: write + checks: write + contents: write + pull-requests: read + +jobs: + prepare-release: + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.prepare.outputs.tag }} + release_sha: ${{ steps.prepare.outputs.release_sha }} + steps: + - uses: actions/checkout@v6 + with: + fetch-tags: true + - uses: ./.github/actions/setup-env + with: + gcp_workload_identity_provider: ${{ secrets.GCP_STREAMX_RELEASES_WORKLOAD_IDENTITY_PROVIDER }} + gcp_service_account: ${{ secrets.GCP_STREAMX_RELEASES_RELEASE_DEPLOY_SA }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare release + id: prepare + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + EXTRA_ARGS="" + PREVIEW=false + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + PREVIEW="${{ inputs.preview }}" + fi + + if [[ "$PREVIEW" == "true" ]]; then + CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout) + BASE_VERSION="${CURRENT_VERSION%-SNAPSHOT}" + SHORT_SHA=$(git rev-parse --short HEAD) + EXTRA_ARGS="-DreleaseVersion=${BASE_VERSION}-rc.${SHORT_SHA} -DdevelopmentVersion=${CURRENT_VERSION}" + fi + + ./mvnw -B release:prepare \ + -Darguments="-DskipTests" \ + -P release \ + $EXTRA_ARGS + + RELEASE_VERSION=$(git describe --tags --abbrev=0) + RELEASE_SHA=$(git rev-list -n 1 "$RELEASE_VERSION") + + git push origin HEAD:${{ github.ref_name }} + + echo "tag=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "Prepared release: $RELEASE_VERSION ($RELEASE_SHA)" + + build: + needs: prepare-release + uses: ./.github/workflows/build-cross-platform.yml + with: + ref: ${{ needs.prepare-release.outputs.release_sha }} + secrets: inherit + + release: + needs: [prepare-release, build] + runs-on: ubuntu-24.04 + outputs: + brew_tap_slug: ${{ steps.release-type.outputs.brew_tap_slug }} + release_repo: ${{ steps.release-type.outputs.release_repo }} + env: + RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare-release.outputs.release_sha }} + fetch-tags: true + - uses: ./.github/actions/setup-env + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download native binary artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: streamx-* + merge-multiple: true + + - name: Download JAR artifacts + uses: actions/download-artifact@v4 + with: + name: streamx-runner-jar + path: target/ + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_STREAMX_RELEASE_APP_ID }} + private-key: ${{ secrets.GH_STREAMX_RELEASE_APP_PRIVATE_KEY }} + owner: streamx-com + repositories: homebrew-tap,homebrew-preview-tap,streamx-cli,streamx-cli-preview + + - name: Determine release type + id: release-type + run: | + if [[ "${{ inputs.preview }}" == "true" ]]; then + echo "type=preview" >> $GITHUB_OUTPUT + echo "jreleaser_config=.github/jreleaser/jreleaser-preview.yml" >> $GITHUB_OUTPUT + echo "brew_tap=homebrew-preview-tap" >> $GITHUB_OUTPUT + echo "brew_tap_slug=streamx-com/preview-tap" >> $GITHUB_OUTPUT + echo "release_repo=streamx-com/streamx-cli-preview" >> $GITHUB_OUTPUT + else + echo "type=stable" >> $GITHUB_OUTPUT + echo "jreleaser_config=.github/jreleaser/jreleaser.yml" >> $GITHUB_OUTPUT + echo "brew_tap=homebrew-tap" >> $GITHUB_OUTPUT + echo "brew_tap_slug=streamx-com/tap" >> $GITHUB_OUTPUT + echo "release_repo=streamx-com/streamx-cli" >> $GITHUB_OUTPUT + fi + echo "Release type: $(grep type <<< "$(cat $GITHUB_OUTPUT)" | head -1)" + + - name: Release with JReleaser + env: + JRELEASER_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + JRELEASER_HOMEBREW_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }} + run: | + chmod +x artifacts/streamx-* + + echo "Pre-create platform ZIPs for native binaries" + for PLATFORM in linux-x86_64 linux-aarch64 macos-aarch64 macos-x86_64; do + mkdir -p staging/$PLATFORM/bin + cp artifacts/streamx-$PLATFORM staging/$PLATFORM/bin/streamx + cp LICENSE.txt staging/$PLATFORM/ + cd staging + zip -r ../artifacts/streamx-${RELEASE_TAG}-${PLATFORM}.zip $PLATFORM/ + cd .. + done + + echo "Prepare Java binary JAR" + cp target/*-runner.jar artifacts/streamx-jar.jar + + ./mvnw jreleaser:assemble jreleaser:full-release \ + -Djreleaser.config.file=${{ steps.release-type.outputs.jreleaser_config }} \ + -Djreleaser.strict=true + + - name: Upload JReleaser output + if: always() + uses: actions/upload-artifact@v4 + with: + name: jreleaser-output + path: | + target/jreleaser/trace.log + target/jreleaser/output.properties + + - name: Create versioned Homebrew formula + env: + HOMEBREW_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + BREW_TAP: ${{ steps.release-type.outputs.brew_tap }} + RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }} + run: | + git clone https://x-access-token:${HOMEBREW_GITHUB_TOKEN}@github.com/streamx-com/${BREW_TAP}.git + cd ${BREW_TAP}/Formula + + FILENAME="streamx@${RELEASE_TAG}.rb" + + if [ -f "$FILENAME" ]; then + echo "Versioned formula already exists, skipping." + exit 0 + fi + + echo "Copy the freshly-pushed unversioned formula" + cp streamx.rb "$FILENAME" + + CLASS_SUFFIX=$(echo "$RELEASE_TAG" | sed -E 's/[^a-zA-Z0-9]+([a-zA-Z])/\U\1/g; s/[^a-zA-Z0-9]//g') + sed -i "s/class Streamx/class StreamxAT${CLASS_SUFFIX}/" "$FILENAME" + + git config user.name "streamx-cli-release-bot[bot]" + git config user.email "264353404+streamx-cli-release-bot[bot]@users.noreply.github.com" + git add "$FILENAME" + git commit -m "Add versioned formula for ${RELEASE_TAG}" + git push + + release-test: + needs: [prepare-release, build, release] + name: ${{ matrix.name }}-release-test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.build.outputs.platforms) }} + env: + RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare-release.outputs.release_sha }} + - uses: ./.github/actions/setup-env + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_STREAMX_RELEASE_APP_ID }} + private-key: ${{ secrets.GH_STREAMX_RELEASE_APP_PRIVATE_KEY }} + owner: streamx-com + repositories: homebrew-tap,homebrew-preview-tap,streamx-cli,streamx-cli-preview + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@main + + - name: Test Homebrew install + run: | + echo "Test latest version" + brew install ${{ needs.release.outputs.brew_tap_slug }}/streamx + streamx --version | grep -q "${RELEASE_TAG}" + brew uninstall streamx + + echo "Test versioned formula" + brew install ${{ needs.release.outputs.brew_tap_slug }}/streamx@${RELEASE_TAG} + streamx --version | grep -q "${RELEASE_TAG}" + brew uninstall streamx@${RELEASE_TAG} + + - name: Test native binary from GitHub release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh release download "${RELEASE_TAG}" \ + --repo "${{ needs.release.outputs.release_repo }}" \ + --pattern "${{ matrix.artifact }}" \ + --dir . + chmod +x ${{ matrix.artifact }} + ./${{ matrix.artifact }} --version | grep -q "${RELEASE_TAG}" + + - name: Test native archive from GitHub release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + ARCHIVE_PLATFORM=$(echo "${{ matrix.artifact }}" | sed 's/^streamx-//') + gh release download "${RELEASE_TAG}" \ + --repo "${{ needs.release.outputs.release_repo }}" \ + --pattern "streamx-*-${ARCHIVE_PLATFORM}.zip" \ + --dir . + unzip streamx-*-${ARCHIVE_PLATFORM}.zip + ./${ARCHIVE_PLATFORM}/bin/streamx --version | grep -q "${RELEASE_TAG}" + + - name: Test JAR archive from GitHub release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh release download "${RELEASE_TAG}" \ + --repo "${{ needs.release.outputs.release_repo }}" \ + --pattern "streamx-jar-*.zip" \ + --dir . + unzip streamx-jar-*.zip + streamx-jar-*/bin/streamx --version | grep -q "${RELEASE_TAG}" + + - name: Test install script + run: ./install.sh.test "${RELEASE_TAG}" \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47f961d0..728542cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,19 @@ - Commands should throw only the [`CliException`](./src/main/java/com/streamx/cli/framework/CliException.java). - All user facing messages should be provided by [`MessageProvider`](./src/main/java/com/streamx/cli/i18n/MessageProvider.java). +## Build + +- Ensure that you have Java 25 GraalVM installed. +- First you need to run `./mvnw clean verify` to build the project with. + It's needed to generate metadata for building native-image in the next step. + +- Then you can run `./mvnw verify -Dnative` **without the clean goal** to build the native executable. + ## Development +**IMPORTANT:** mark test which run StreamX mesh with the `DisabledIfDockerUnavaliable` annotation. +Otherwise, CI will fail because at this moment Docker isn't supported on macOS arm64. + - Enter Quarkus development console. `./mvnw quarkus:dev` @@ -17,4 +28,8 @@ ## Running tests -`./mvnw test` \ No newline at end of file +`./mvnw verify -Dnative` + +## Release process + +Use `./release.sh ` script. \ No newline at end of file diff --git a/README.md b/README.md index 68bca310..420e3e76 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,70 @@ -# **StreamX CLI v2.x** +# StreamX CLI v2.x + +[StreamX](https://streamx.dev) is a globally distributed experience delivery that outperforms traditional CDNs. + +![logo](./logo.svg) This project provides utilities for managing the mesh: -* It allows to run a defined mesh from commands, -* It allows to ingest data into mesh. +* It allows you to run a defined mesh from commands. +* It allows you to ingest data into a mesh. For more information, see the [StreamX CLI Reference](https://www.streamx.dev/guides/main/streamx-command-line-interface-reference.html). -⚠️ Please read the [contributing guidelines](./CONTRIBUTING.md) if you're a developer and wish to contribute to the project. +Please read the [contributing guidelines](./CONTRIBUTING.md) if you're a developer and wish to contribute to the project. + +## Installation + +### Install from releases page (macOS, Linux) + +You can find all the available stable releases on [GitHub releases page](https://github.com/streamx-com/streamx-cli/releases). + +### Install using Homebrew (macOS, Linux) + +**Install latest stable version:** + +```sh +brew install streamx-com/tap/streamx +``` + +**Install specific stable version:** + +```sh +# Add brew tap +brew tap streamx-com/tap + +# List available formulas +brew search streamx-com/tap + +# Install the specific versioned formula from the previous step +brew install +``` + +**Install preview version:** + +Use the commands above as for stable releases, but replace `streamx-com/tap` with `streamx-com/preview-tap`. + +### Install using curl (macOS, Linux) + +**Install latest stable version to current directory:** + +```sh +curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sh +``` + +**Install globally (requires sudo):** + +```sh +curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sudo sh -s -- --dest /usr/local/bin +``` + +**Install specific version:** + +```sh +curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sh -s -- --version 1.0.0 +``` ## Configuration There are several ways of configuring and several properties to configure. -For details refer to [StreamX CLI Reference](https://www.streamx.dev/guides/streamx-command-line-interface-reference.html). +For details refer to [StreamX CLI Reference](https://www.streamx.dev/guides/streamx-command-line-interface-reference.html). \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..c6ac868a --- /dev/null +++ b/install.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env sh +# install.sh - Install StreamX CLI +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sh -s -- --version 1.0.0 +# curl -fsSL https://raw.githubusercontent.com/streamx-com/streamx-cli/main/install.sh | sh -s -- --version 1.0.0-rc.1.abc1234 --dest /usr/local/bin + +set -eu + +REPO_STABLE="streamx-com/streamx-cli" +REPO_PREVIEW="streamx-com/streamx-cli-preview" +BINARY_NAME="streamx" +INSTALL_DIR="." +VERSION="" + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --version|-v) VERSION="$2"; shift 2 ;; + --dest|-d) INSTALL_DIR="$2"; shift 2 ;; + --help|-h) + echo "Usage: install.sh [--version ] [--dest ]" + echo "" + echo "Options:" + echo " --version, -v Version to install (default: latest stable release)" + echo " --dest, -d Installation directory (default: current directory)" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { echo "[streamx] $*"; } +error() { echo "[streamx] ERROR: $*" >&2; exit 1; } + +need_cmd() { + if ! command -v "$1" > /dev/null 2>&1; then + error "Required command '$1' not found. Please install it and retry." + fi +} + +# Compute SHA-256 using whichever tool is available. +# sha256sum is standard on Linux; shasum ships with macOS. +compute_sha256() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + elif command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + else + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Detect platform +# --------------------------------------------------------------------------- +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + linux*) OS="linux" ;; + darwin*) OS="macos" ;; + *) error "Unsupported operating system: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x86_64" ;; + aarch64|arm64) ARCH="aarch64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + ARTIFACT="streamx-${OS}-${ARCH}" +} + +# --------------------------------------------------------------------------- +# Resolve version & repo +# --------------------------------------------------------------------------- +resolve_version() { + if [ -n "$VERSION" ]; then + # Preview versions contain "-rc." + case "$VERSION" in + *-rc.*) REPO="$REPO_PREVIEW" ;; + *) REPO="$REPO_STABLE" ;; + esac + TAG="$VERSION" + else + REPO="$REPO_STABLE" + TAG="latest" + fi +} + +# --------------------------------------------------------------------------- +# Download & install +# --------------------------------------------------------------------------- +download_and_install() { + need_cmd uname + need_cmd chmod + need_cmd mkdir + + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + + if [ "$TAG" = "latest" ]; then + DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ARTIFACT}" + else + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${TAG}/${ARTIFACT}" + fi + + info "Downloading ${BINARY_NAME} from ${DOWNLOAD_URL} ..." + + if command -v curl > /dev/null 2>&1; then + HTTP_CODE=$(curl -#fSL --retry 5 --retry-delay 5 -w "%{http_code}" -o "${TMPDIR}/${ARTIFACT}" "$DOWNLOAD_URL") || true + if [ "$HTTP_CODE" != "200" ]; then + error "Download failed (HTTP ${HTTP_CODE}). Check that version '${TAG}' exists at https://github.com/${REPO}/releases" + fi + elif command -v wget > /dev/null 2>&1; then + wget --show-progress -q -O "${TMPDIR}/${ARTIFACT}" "$DOWNLOAD_URL" || error "Download failed. Check that version '${TAG}' exists at https://github.com/${REPO}/releases" + else + error "Neither 'curl' nor 'wget' found. Please install one of them and retry." + fi + + # --------------------------------------------------------------------------- + # Verify checksum + # --------------------------------------------------------------------------- + CHECKSUMS_URL="${DOWNLOAD_URL%/*}/checksums_sha256.txt" + info "Verifying checksum ..." + + CHECKSUMS_FILE="${TMPDIR}/checksums_sha256.txt" + if command -v curl > /dev/null 2>&1; then + curl -fsSL -o "$CHECKSUMS_FILE" "$CHECKSUMS_URL" 2>/dev/null || true + elif command -v wget > /dev/null 2>&1; then + wget -q -O "$CHECKSUMS_FILE" "$CHECKSUMS_URL" 2>/dev/null || true + fi + + if [ -s "$CHECKSUMS_FILE" ]; then + EXPECTED=$(grep "${ARTIFACT}$" "$CHECKSUMS_FILE" | cut -d ' ' -f 1) + if [ -n "$EXPECTED" ]; then + ACTUAL=$(compute_sha256 "${TMPDIR}/${ARTIFACT}") || error "No SHA-256 tool found (sha256sum or shasum). Cannot verify checksum." + if [ "$EXPECTED" = "$ACTUAL" ]; then + info "Checksum verified: ${ACTUAL}" + else + error "Checksum mismatch! Expected ${EXPECTED}, got ${ACTUAL}. The download may be corrupted." + fi + else + info "Warning: no checksum entry found for ${ARTIFACT}, skipping verification." + fi + else + info "Warning: checksums file not available, skipping verification." + fi + + chmod +x "${TMPDIR}/${ARTIFACT}" + + # Install to destination + mkdir -p "$INSTALL_DIR" 2>/dev/null || true + if [ -w "$INSTALL_DIR" ]; then + mv "${TMPDIR}/${ARTIFACT}" "${INSTALL_DIR}/${BINARY_NAME}" + else + info "Elevated permissions required to install to ${INSTALL_DIR}" + sudo mv "${TMPDIR}/${ARTIFACT}" "${INSTALL_DIR}/${BINARY_NAME}" + fi + + # Resolve to absolute path for clear messaging + INSTALL_DIR=$(cd "$INSTALL_DIR" && pwd) + + info "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" +} + +# --------------------------------------------------------------------------- +# Verify installation +# --------------------------------------------------------------------------- +verify() { + # Determine how the user should invoke the binary + case ":$PATH:" in + *:"$INSTALL_DIR":*) STREAMX_CMD="streamx" ;; + *) STREAMX_CMD="${INSTALL_DIR}/streamx" ;; + esac + + if [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then + INSTALLED_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" --version 2>/dev/null || echo "unknown") + info "Verified: ${INSTALLED_VERSION}" + else + error "Installation could not be verified." + fi + + if ! echo ":$PATH:" | grep -q ":${INSTALL_DIR}:"; then + info "Note: ${INSTALL_DIR} is not in your PATH. Add it with:" + echo "" + echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" + echo "" + fi +} + +# --------------------------------------------------------------------------- +# Print usage +# --------------------------------------------------------------------------- +print_usage() { + echo "" + echo "StreamX CLI installed successfully!" + echo "" + echo "Get started:" + echo "" + echo " ${STREAMX_CMD} --help Show all available commands" + echo " ${STREAMX_CMD} --version Print installed version" + echo "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +detect_platform +resolve_version +download_and_install +verify +print_usage \ No newline at end of file diff --git a/install.sh.test b/install.sh.test new file mode 100755 index 00000000..ead273dc --- /dev/null +++ b/install.sh.test @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# install-test.sh - Tests for install.sh +# +# Usage: ./install-test.sh +# +# Requires the release to already be published on GitHub. + +set -euo pipefail + +VERSION="${1:?Usage: install-test.sh }" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SCRIPT="${SCRIPT_DIR}/install.sh" +PASS=0 +FAIL=0 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +info() { echo ""; echo "=== $* ==="; } +pass() { echo "PASS: $*"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $*"; FAIL=$((FAIL + 1)); } + +assert_version() { + local binary="$1" + local expected="$2" + if "$binary" --version | grep -q "$expected"; then + pass "$binary --version contains '$expected'" + else + fail "$binary --version does not contain '$expected' (got: $("$binary" --version 2>&1))" + fi +} + +assert_executable() { + local binary="$1" + if [ -x "$binary" ]; then + pass "$binary is executable" + else + fail "$binary is not executable" + fi +} + +cleanup() { + rm -rf "$WORKDIR" +} + +WORKDIR=$(mktemp -d) +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Test 1: Install to current directory (default) +# --------------------------------------------------------------------------- +info "Test 1: Default install (current directory)" + +TEST_DIR="${WORKDIR}/test-default" +mkdir -p "$TEST_DIR" +(cd "$TEST_DIR" && sh "$INSTALL_SCRIPT" --version "$VERSION") + +assert_executable "${TEST_DIR}/streamx" +assert_version "${TEST_DIR}/streamx" "$VERSION" + +# --------------------------------------------------------------------------- +# Test 2: Install with --dest flag +# --------------------------------------------------------------------------- +info "Test 2: Install with --dest flag" + +DEST_DIR="${WORKDIR}/test-dest/custom/path" +sh "$INSTALL_SCRIPT" --version "$VERSION" --dest "$DEST_DIR" + +assert_executable "${DEST_DIR}/streamx" +assert_version "${DEST_DIR}/streamx" "$VERSION" + +# --------------------------------------------------------------------------- +# Test 3: Install with -v and -d short flags +# --------------------------------------------------------------------------- +info "Test 3: Install with short flags (-v, -d)" + +SHORT_DIR="${WORKDIR}/test-short" +sh "$INSTALL_SCRIPT" -v "$VERSION" -d "$SHORT_DIR" + +assert_executable "${SHORT_DIR}/streamx" +assert_version "${SHORT_DIR}/streamx" "$VERSION" + +# --------------------------------------------------------------------------- +# Test 4: Overwrite existing installation +# --------------------------------------------------------------------------- +info "Test 4: Overwrite existing installation" + +OVERWRITE_DIR="${WORKDIR}/test-overwrite" +mkdir -p "$OVERWRITE_DIR" +echo "placeholder" > "${OVERWRITE_DIR}/streamx" + +sh "$INSTALL_SCRIPT" --version "$VERSION" --dest "$OVERWRITE_DIR" + +assert_executable "${OVERWRITE_DIR}/streamx" +assert_version "${OVERWRITE_DIR}/streamx" "$VERSION" + +# --------------------------------------------------------------------------- +# Test 5: --help flag exits successfully +# --------------------------------------------------------------------------- +info "Test 5: --help flag" + +HELP_OUTPUT=$(sh "$INSTALL_SCRIPT" --help 2>&1 || true) +if echo "$HELP_OUTPUT" | grep -q "Usage"; then + pass "--help prints usage" +else + fail "--help did not print usage" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "===========================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===========================================" + +[ "$FAIL" -eq 0 ] || exit 1 \ No newline at end of file diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..b09a7fc4 --- /dev/null +++ b/logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mvnw b/mvnw index 8d937f4c..b735ac1b 100755 --- a/mvnw +++ b/mvnw @@ -281,6 +281,7 @@ if [ -n "$wrapperSha256Sum" ]; then fi MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" +MAVEN_OPTS="--enable-native-access=ALL-UNNAMED $MAVEN_OPTS" # For Cygwin, switch paths to Windows format before running java if $cygwin; then diff --git a/pom.xml b/pom.xml index faf88bb4..c2c2eb9d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,25 +1,35 @@ - + 4.0.0 com.streamx.cli streamx-cli - 2.0.0-SNAPSHOT + 0.5.13-SNAPSHOT + + + scm:git:https://github.com/streamx-com/streamx-cli.git + scm:git:https://github.com/streamx-com/streamx-cli.git + https://github.com/streamx-com/streamx-cli + HEAD + - 21 - 21 + 21 UTF-8 UTF-8 3.30.6 3.2.5 3.13.0 3.6.0 - 2.0.24 + 25.0.2 + 2.0.26 4.0.1 + + false + + + -agentlib:native-image-agent=config-output-dir=${project.basedir}/src/main/resources/META-INF/native-image + @@ -148,26 +158,44 @@ cloudevents-core ${cloudevents.version} + io.cloudevents cloudevents-json-jackson ${cloudevents.version} + + + org.graalvm.sdk + graal-sdk + ${graalvm.version} + provided + + + + org.graalvm.nativeimage + svm + ${graalvm.version} + provided + + - io.fabric8 - kubernetes-client-api + org.fusesource.jansi + jansi + 2.4.1 - io.fabric8 - kubernetes-model-apiextensions + com.github.albfernandez + juniversalchardet + 2.5.0 - org.testcontainers - testcontainers - 2.0.3 + org.brotli + dec + 0.1.2 @@ -281,11 +309,13 @@ ${quarkus.platform.version} true - - --add-opens java.base/java.lang=ALL-UNNAMED + + + --add-opens java.base/java.lang=ALL-UNNAMED + @@ -297,6 +327,19 @@ + + org.jreleaser + jreleaser-maven-plugin + 1.22.0 + + + org.apache.maven.plugins + maven-release-plugin + + @{project.version} + [no ci] [maven-release-plugin] + + org.apache.maven.plugins maven-checkstyle-plugin @@ -325,6 +368,19 @@ + + maven-clean-plugin + + + + src/main/resources/META-INF/native-image + + reachability-metadata.json + + + + + maven-compiler-plugin ${compiler-plugin.version} @@ -342,14 +398,15 @@ maven-surefire-plugin ${surefire-plugin.version} - - --add-opens java.base/java.lang=ALL-UNNAMED + ${test.output.toFile} org.jboss.logmanager.LogManager + + --add-opens java.base/java.lang=ALL-UNNAMED + --enable-native-access=ALL-UNNAMED + ${native.agent.argLine} + @@ -364,9 +421,35 @@ + ${test.output.toFile} org.jboss.logmanager.LogManager + + --add-opens java.base/java.lang=ALL-UNNAMED + ${native.agent.argLine} + + + + + org.graalvm.buildtools + native-maven-plugin + 0.11.4 + true + + + true + + + + + maven-jar-plugin + + + + java.base/java.lang + + @@ -380,6 +463,17 @@ + + ci + + + ci + + + + true + + native @@ -388,11 +482,23 @@ - false native + true + true + + + + + maven-surefire-plugin + + + true + + + maven-failsafe-plugin @@ -405,4 +511,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/streamx/cli/commands/publish/PublishCommand.java b/src/main/java/com/streamx/cli/commands/publish/PublishCommand.java index 1c6dc160..020e865e 100644 --- a/src/main/java/com/streamx/cli/commands/publish/PublishCommand.java +++ b/src/main/java/com/streamx/cli/commands/publish/PublishCommand.java @@ -7,6 +7,7 @@ @CommandLine.Command( name = "publish", mixinStandardHelpOptions = true, + header = "Publish events", subcommands = { StreamCommand.class, } diff --git a/src/main/java/com/streamx/cli/commands/publish/stream/SourceStream.java b/src/main/java/com/streamx/cli/commands/publish/stream/SourceStream.java index e3c3e3a5..09aa4284 100644 --- a/src/main/java/com/streamx/cli/commands/publish/stream/SourceStream.java +++ b/src/main/java/com/streamx/cli/commands/publish/stream/SourceStream.java @@ -5,6 +5,8 @@ import com.streamx.cli.framework.CliException; import jakarta.ws.rs.core.Response; import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; import java.io.SequenceInputStream; import java.net.URI; @@ -17,11 +19,6 @@ public class SourceStream { - private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(10)) - .build(); - public static InputStream get(String source) throws CliException { InputStream input; if (source != null) { @@ -60,21 +57,36 @@ public static InputStream get(String source) throws CliException { } private static InputStream openHttpStream(URI uri) throws CliException { + HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + try { HttpRequest request = HttpRequest.newBuilder(uri).GET().build(); HttpResponse response = - HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofInputStream()); + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); int status = response.statusCode(); if (status < 200 || status >= 300) { String statusText = Response.Status.fromStatusCode(status).getReasonPhrase(); - + httpClient.close(); throw new CliException("HTTP " + status + " " + statusText); } - return response.body(); + return new FilterInputStream(response.body()) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + httpClient.close(); + } + } + }; } catch (Exception e) { + httpClient.close(); throw new CliException(e.getMessage() == null ? msg.connectionRefused() : e.getMessage(), e); } } diff --git a/src/main/java/com/streamx/cli/commands/publish/stream/StreamCommand.java b/src/main/java/com/streamx/cli/commands/publish/stream/StreamCommand.java index f8a0d6a3..f92c2ecd 100644 --- a/src/main/java/com/streamx/cli/commands/publish/stream/StreamCommand.java +++ b/src/main/java/com/streamx/cli/commands/publish/stream/StreamCommand.java @@ -15,6 +15,7 @@ import com.streamx.clients.ingestion.exceptions.StreamxClientException; import com.streamx.clients.ingestion.publisher.Publisher; import io.cloudevents.CloudEvent; +import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; @@ -34,8 +35,7 @@ public class StreamCommand extends AbstractCommand { @CommandLine.Parameters( index = "0", description = "Events source. It can be a file path or resource URI", - arity = "0..1", - defaultValue = CommandLine.Parameters.NULL_VALUE + arity = "0..1" ) public String source; @@ -92,9 +92,10 @@ public CommandResult runCommand() { System.err.println(IngestionClientConfig.prettyPrint(ingestionClientConfig)); } - InputStream sourceStream = SourceStream.get(source); - - try (StreamxClient streamxClient = StreamxClientFactory.create(ingestionClientConfig)) { + try ( + InputStream sourceStream = SourceStream.get(source); + StreamxClient streamxClient = StreamxClientFactory.create(ingestionClientConfig) + ) { try { try (Stream jsonStream = ConcatenatedJsonSerde.parse(sourceStream)) { Publisher publisher = streamxClient.newPublisher(); @@ -161,6 +162,8 @@ public CommandResult runCommand() { } } catch (StreamxClientException e) { throw new CliException(msg.unableToCreateStreamxClient(ingestionClientConfig.url()), e); + } catch (IOException e) { + throw new CliException(msg.unableToPublishStream(source), e); } return prepareResult(); diff --git a/src/main/java/com/streamx/cli/framework/AbstractCommand.java b/src/main/java/com/streamx/cli/framework/AbstractCommand.java index 3bf58191..dbbe287f 100644 --- a/src/main/java/com/streamx/cli/framework/AbstractCommand.java +++ b/src/main/java/com/streamx/cli/framework/AbstractCommand.java @@ -133,6 +133,9 @@ public int execute() { exitCode = handleExecutionError(e); } + System.out.flush(); + System.err.flush(); + return exitCode; } diff --git a/src/main/java/com/streamx/cli/ingestion/IngestionClientPicocliOptions.java b/src/main/java/com/streamx/cli/ingestion/IngestionClientPicocliOptions.java index 935e2ad0..1071de77 100644 --- a/src/main/java/com/streamx/cli/ingestion/IngestionClientPicocliOptions.java +++ b/src/main/java/com/streamx/cli/ingestion/IngestionClientPicocliOptions.java @@ -9,9 +9,7 @@ public class IngestionClientPicocliOptions { @CommandLine.Option( names = {"--ingestion-url", "-u"}, - description = "StreamX ingestion URL", - defaultValue = "${streamx.ingestion.url}", - fallbackValue = CommandLine.Parameters.NULL_VALUE + description = "StreamX ingestion URL" ) public String url; @@ -38,7 +36,7 @@ public IngestionClientConfig getIngestionClientConfig() { return new IngestionClientConfig() { @Override public String url() { - return url == null ? originalConfig.url() : url; + return (url == null || url.isEmpty()) ? originalConfig.url() : url; } @Override diff --git a/src/main/java/com/streamx/cli/mesh/MeshManager.java b/src/main/java/com/streamx/cli/mesh/MeshManager.java index f07f3d91..571c82d7 100644 --- a/src/main/java/com/streamx/cli/mesh/MeshManager.java +++ b/src/main/java/com/streamx/cli/mesh/MeshManager.java @@ -134,7 +134,7 @@ private void doStop() { public void reload() { ServiceMesh newServiceMesh = errorHandlingExecutor.execute(() -> { - var serviceMesh = resolveMeshDefinition(meshPath); + ServiceMesh serviceMesh = resolveMeshDefinition(meshPath); serviceMesh.validate().assertValid(); return serviceMesh; diff --git a/src/main/java/com/streamx/cli/mesh/MeshWatcher.java b/src/main/java/com/streamx/cli/mesh/MeshWatcher.java index eefaf8d4..30cef48b 100644 --- a/src/main/java/com/streamx/cli/mesh/MeshWatcher.java +++ b/src/main/java/com/streamx/cli/mesh/MeshWatcher.java @@ -79,7 +79,7 @@ private static ActionToPerform checkModifications(Path meshPath, WatchService wa ActionToPerform lastAction = null; WatchKey polledKey = watchService.poll(); if (polledKey != null) { - for (var event : polledKey.pollEvents()) { + for (WatchEvent event : polledKey.pollEvents()) { if (pathsMatches(meshPath, event)) { if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE) { lastAction = ActionToPerform.RELOAD; diff --git a/src/main/java/com/streamx/cli/util/JacksonUtils.java b/src/main/java/com/streamx/cli/util/JacksonUtils.java index c954e3a9..35030912 100644 --- a/src/main/java/com/streamx/cli/util/JacksonUtils.java +++ b/src/main/java/com/streamx/cli/util/JacksonUtils.java @@ -1,5 +1,7 @@ package com.streamx.cli.util; +import com.fasterxml.jackson.core.JsonLocation; + public class JacksonUtils { public static String formatException(Exception e) { for (Throwable t = e; t != null; t = t.getCause()) { @@ -9,7 +11,7 @@ public static String formatException(Exception e) { "\\s*\\(not recognized as one since Feature '.*?' not enabled for parser\\)", "" ); - var loc = jpe.getLocation(); + JsonLocation loc = jpe.getLocation(); if (loc != null) { return "%s (line: %d, column: %d)".formatted( message, loc.getLineNr(), loc.getColumnNr()); diff --git a/src/main/java/com/streamx/cli/util/StreamxMavenPropertiesUtils.java b/src/main/java/com/streamx/cli/util/StreamxMavenPropertiesUtils.java index 74aac142..5f32fa52 100644 --- a/src/main/java/com/streamx/cli/util/StreamxMavenPropertiesUtils.java +++ b/src/main/java/com/streamx/cli/util/StreamxMavenPropertiesUtils.java @@ -16,7 +16,7 @@ public class StreamxMavenPropertiesUtils { Properties properties = null; if (url != null) { try { - var loadedProperties = new Properties(); + Properties loadedProperties = new Properties(); loadedProperties.load(url.openStream()); properties = loadedProperties; diff --git a/src/main/java/com/streamx/cli/util/VersionProvider.java b/src/main/java/com/streamx/cli/util/VersionProvider.java index d61ef983..cebf9cf7 100644 --- a/src/main/java/com/streamx/cli/util/VersionProvider.java +++ b/src/main/java/com/streamx/cli/util/VersionProvider.java @@ -8,7 +8,7 @@ public class VersionProvider implements IVersionProvider { @Override public String[] getVersion() { - var streamxVersion = StreamxMavenPropertiesUtils.getStreamxCliVersion(); + String streamxVersion = StreamxMavenPropertiesUtils.getStreamxCliVersion(); if (streamxVersion == null) { return new String[]{msg.noVersionInformationIncluded()}; } diff --git a/src/main/resources/META-INF/native-image/.gitignore b/src/main/resources/META-INF/native-image/.gitignore new file mode 100644 index 00000000..99a0c0f9 --- /dev/null +++ b/src/main/resources/META-INF/native-image/.gitignore @@ -0,0 +1,2 @@ +/reachability-metadata.json +/.lock \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cb541572..25a44007 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,26 @@ +quarkus.package.jar.type=uber-jar + quarkus.banner.enabled=false quarkus.log.level=ERROR quarkus.log.console.enable=true quarkus.log.console.level=ERROR quarkus.log.category."io.quarkus".level=OFF +quarkus.log.category."io.quarkus.runtime".level=ERROR quarkus.log.category."org.jboss".level=OFF + +# Native image +quarkus.native.additional-build-args=\ + -Os,\ + --enable-native-access=ALL-UNNAMED,\ + --initialize-at-run-time=org.jline.nativ,\ + --initialize-at-run-time=org.jline.terminal.impl.jansi,\ + --initialize-at-run-time=org.jline.terminal.impl.ffm,\ + --initialize-at-run-time=jdk.internal.org.jline.terminal.impl.ffm,\ + --initialize-at-run-time=com.github.dockerjava.transport.NamedPipeSocket$Kernel32,\ + --initialize-at-run-time=org.newsclub.net.unix,\ + --initialize-at-run-time=com.fasterxml.jackson.module.jaxb.deser.DataHandlerJsonDeserializer + +# Fixes "Cannot load required properties from maven-build.properties" error +# when run streamx-runner with native-image executable. +quarkus.native.resources.includes=maven-build.properties \ No newline at end of file diff --git a/src/test/java/com/streamx/cli/commands/StreamxCommandTest.java b/src/test/java/com/streamx/cli/commands/StreamxCommandIT.java similarity index 82% rename from src/test/java/com/streamx/cli/commands/StreamxCommandTest.java rename to src/test/java/com/streamx/cli/commands/StreamxCommandIT.java index dc493d5d..a107f6a2 100644 --- a/src/test/java/com/streamx/cli/commands/StreamxCommandTest.java +++ b/src/test/java/com/streamx/cli/commands/StreamxCommandIT.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.test.CliBaseIT; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import java.util.HashSet; @@ -11,7 +12,7 @@ import picocli.CommandLine; @QuarkusTest -class StreamxCommandTest { +class StreamxCommandIT extends CliBaseIT { @Inject CommandLine.IFactory factory; @@ -43,4 +44,12 @@ private void collectAllCommands( collectAllCommands(subcommand.getCommandSpec(), commands); } } + + @Test + void shouldPrintHelpInformation() throws Exception { + ProcessResult result = exec(); + + assertThat(result.stdout()).contains("StreamX CLI. More info at"); + assertThat(result.stderr()).isEmpty(); + } } diff --git a/src/test/java/com/streamx/cli/commands/local/run/RunCommandTest.java b/src/test/java/com/streamx/cli/commands/local/run/RunCommandIT.java similarity index 86% rename from src/test/java/com/streamx/cli/commands/local/run/RunCommandTest.java rename to src/test/java/com/streamx/cli/commands/local/run/RunCommandIT.java index 090e836b..45168bdc 100644 --- a/src/test/java/com/streamx/cli/commands/local/run/RunCommandTest.java +++ b/src/test/java/com/streamx/cli/commands/local/run/RunCommandIT.java @@ -4,20 +4,24 @@ import static org.assertj.core.api.Assertions.assertThat; import com.streamx.cli.test.CliBaseIT; +import com.streamx.cli.test.annotation.DisabledIfDockerUnavailable; import io.quarkus.test.junit.main.LaunchResult; import io.quarkus.test.junit.main.QuarkusMainLauncher; import io.quarkus.test.junit.main.QuarkusMainTest; import java.nio.file.Paths; +import java.time.Duration; import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @QuarkusMainTest -public class RunCommandTest extends CliBaseIT { +@DisabledIfDockerUnavailable +public class RunCommandIT extends CliBaseIT { @AfterEach void awaitDockerResourcesAreRemoved() { Awaitility.await() + .atMost(Duration.ofMinutes(2)) .until(() -> { try { cleanUpMesh( diff --git a/src/test/java/com/streamx/cli/commands/publish/PublishCommandIT.java b/src/test/java/com/streamx/cli/commands/publish/PublishCommandIT.java new file mode 100644 index 00000000..ea1e66ed --- /dev/null +++ b/src/test/java/com/streamx/cli/commands/publish/PublishCommandIT.java @@ -0,0 +1,20 @@ +package com.streamx.cli.commands.publish; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.test.CliBaseIT; +import com.streamx.cli.test.annotation.DisabledIfDockerUnavailable; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@DisabledIfDockerUnavailable +public class PublishCommandIT extends CliBaseIT { + @Test + void shouldPrintHelpInformation() throws Exception { + ProcessResult result = exec("publish", "stream", "--help"); + + assertThat(result.stdout()).contains("Publish events"); + assertThat(result.stderr()).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIT.java b/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIT.java index 90995cb2..07ef7e85 100644 --- a/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIT.java +++ b/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIT.java @@ -10,11 +10,13 @@ import com.streamx.cli.ingestion.ConcatenatedJsonSerde; import com.streamx.cli.test.CliBaseIT; import com.streamx.cli.test.CloudEventGenerator; +import com.streamx.cli.test.annotation.DisabledIfDockerUnavailable; import com.streamx.cli.test.profiles.DefaultMeshTestProfile; import com.sun.net.httpserver.HttpServer; import io.cloudevents.CloudEvent; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; @@ -24,10 +26,19 @@ import org.junit.jupiter.api.io.TempDir; @QuarkusTest +@DisabledIfDockerUnavailable @TestProfile(DefaultMeshTestProfile.class) public class StreamCommandIT extends CliBaseIT { CloudEventGenerator cloudEventGenerator = new CloudEventGenerator(); + @Test + void shouldPrintHelpInformation() throws Exception { + ProcessResult result = exec("publish", "stream", "--help"); + + assertThat(result.stdout()).contains("Publishes stream of events"); + assertThat(result.stderr()).isEmpty(); + } + @Test void shouldStreamEventsFromFilePath(@TempDir Path tempDir) throws Exception { List events = cloudEventGenerator.generate(5); @@ -85,7 +96,7 @@ void shouldStreamEventsFromHttpUri() throws Exception { server.createContext("/events", exchange -> { byte[] responseBytes = eventsJsonString.getBytes(); exchange.sendResponseHeaders(200, responseBytes.length); - try (var outputStream = exchange.getResponseBody()) { + try (OutputStream outputStream = exchange.getResponseBody()) { outputStream.write(responseBytes); } }); @@ -162,8 +173,6 @@ void shouldFailOnInvalidJson() throws Exception { ); } - - @Test void shouldFormatOutputAsValidJsonIfErrorOccurredOrVerboseFlagIsSet() throws Exception { List validEvents = cloudEventGenerator.generate(3); @@ -303,6 +312,7 @@ void shouldFormatOutputAsValidJsonIfErrorOccurredOrVerboseFlagIsSet() throws Exc @Nested @QuarkusTest + @DisabledIfDockerUnavailable @TestProfile(DefaultMeshTestProfile.class) class BatchStreaming { @@ -455,6 +465,7 @@ void shouldContinueOnFailedBatchIfContinueOnErrorFlagProvided() throws Exception @Nested @QuarkusTest + @DisabledIfDockerUnavailable @TestProfile(DefaultMeshTestProfile.class) class ContinueOnError { @Test @@ -628,6 +639,7 @@ void shouldContinueOnFailedBatchIfContinueOnErrorFlagProvided() throws Exception @Nested @QuarkusTest + @DisabledIfDockerUnavailable @TestProfile(DefaultMeshTestProfile.class) class InvalidSource { @Test diff --git a/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIngestionConfigIT.java b/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIngestionConfigIT.java index 7a8048d4..5e6bce55 100644 --- a/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIngestionConfigIT.java +++ b/src/test/java/com/streamx/cli/commands/publish/stream/StreamCommandIngestionConfigIT.java @@ -10,6 +10,7 @@ import com.streamx.cli.test.CliBaseIT; import com.streamx.cli.test.CloudEventGenerator; import com.streamx.cli.test.MeshTestEnv; +import com.streamx.cli.test.annotation.DisabledIfDockerUnavailable; import com.streamx.cli.test.profiles.MeshWithAuthTestProfile; import io.cloudevents.CloudEvent; import io.quarkus.test.junit.QuarkusTest; @@ -19,6 +20,7 @@ import org.junit.jupiter.api.Test; @QuarkusTest +@DisabledIfDockerUnavailable @TestProfile(MeshWithAuthTestProfile.class) public class StreamCommandIngestionConfigIT extends CliBaseIT { CloudEventGenerator cloudEventGenerator = new CloudEventGenerator(); diff --git a/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java b/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java index d6dfa25f..852b69b0 100644 --- a/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java +++ b/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java @@ -11,6 +11,7 @@ import com.streamx.cli.framework.testing.TestObject; import java.io.ByteArrayInputStream; import java.util.List; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import picocli.CommandLine; diff --git a/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java b/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java index af50ec4f..7d172ebc 100644 --- a/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java +++ b/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java @@ -62,4 +62,4 @@ protected Terminal createTerminal() throws IOException { .dumb(true) .build(); } -} +} \ No newline at end of file diff --git a/src/test/java/com/streamx/cli/ingestion/IngestionClientPicocliOptionsTest.java b/src/test/java/com/streamx/cli/ingestion/IngestionClientPicocliOptionsTest.java index eb7860b1..2571acc7 100644 --- a/src/test/java/com/streamx/cli/ingestion/IngestionClientPicocliOptionsTest.java +++ b/src/test/java/com/streamx/cli/ingestion/IngestionClientPicocliOptionsTest.java @@ -24,7 +24,7 @@ void shouldOverrideAllConfigValuesWithCliOptions() { System.setProperty(IngestionClientConfig.STREAMX_INGESTION_AUTH_TOKEN, "config-token"); System.setProperty(IngestionClientConfig.STREAMX_INGESTION_INSECURE, "false"); - var options = new IngestionClientPicocliOptions(); + IngestionClientPicocliOptions options = new IngestionClientPicocliOptions(); options.url = "http://cli-url:9090"; options.authToken = "cli-token"; options.insecure = true; @@ -42,7 +42,7 @@ void shouldFallBackToAllConfigValuesWhenCliOptionsAreNull() { System.setProperty(IngestionClientConfig.STREAMX_INGESTION_AUTH_TOKEN, "config-token"); System.setProperty(IngestionClientConfig.STREAMX_INGESTION_INSECURE, "true"); - var options = new IngestionClientPicocliOptions(); + IngestionClientPicocliOptions options = new IngestionClientPicocliOptions(); options.url = null; options.authToken = null; options.insecure = null; @@ -60,7 +60,7 @@ class PrettyPrintTests { @Test void shouldMaskAuthTokenInOutput() { - var options = new IngestionClientPicocliOptions(); + IngestionClientPicocliOptions options = new IngestionClientPicocliOptions(); options.url = "http://test:8080"; options.authToken = "my-secret-token"; options.insecure = true; @@ -76,7 +76,7 @@ void shouldMaskAuthTokenInOutput() { @Test void shouldContainAllConfigKeys() { - var options = new IngestionClientPicocliOptions(); + IngestionClientPicocliOptions options = new IngestionClientPicocliOptions(); options.url = "http://localhost:8080"; options.insecure = false; diff --git a/src/test/java/com/streamx/cli/interpolation/InterpolatingMapperTest.java b/src/test/java/com/streamx/cli/interpolation/InterpolatingMapperTest.java index 1049dd37..9d00e833 100644 --- a/src/test/java/com/streamx/cli/interpolation/InterpolatingMapperTest.java +++ b/src/test/java/com/streamx/cli/interpolation/InterpolatingMapperTest.java @@ -20,7 +20,7 @@ public class InterpolatingMapperTest { @AfterEach void cleanup() { - var propertiesToClear = Set.of("test.interpolatedValue", "test.nestedValue", + Set propertiesToClear = Set.of("test.interpolatedValue", "test.nestedValue", "test.arrayValue1", "test.arrayValue2", "test.string", "test.int", "test.boolean"); propertiesToClear.forEach(System::clearProperty); diff --git a/src/test/java/com/streamx/cli/interpolation/InterpolationSupportTest.java b/src/test/java/com/streamx/cli/interpolation/InterpolationSupportTest.java index 5224c22a..8b4c48f8 100644 --- a/src/test/java/com/streamx/cli/interpolation/InterpolationSupportTest.java +++ b/src/test/java/com/streamx/cli/interpolation/InterpolationSupportTest.java @@ -19,7 +19,7 @@ public class InterpolationSupportTest { @AfterEach void cleanup() { - var propertiesToClear = Set.of( + Set propertiesToClear = Set.of( "this.is.a.property", "prop1", "prop2", "nested", "inner", "whitespace.property"); propertiesToClear.forEach(System::clearProperty); diff --git a/src/test/java/com/streamx/cli/mesh/MeshDefinitionResolverTest.java b/src/test/java/com/streamx/cli/mesh/MeshDefinitionResolverTest.java index dfaa26f5..8ff24d60 100644 --- a/src/test/java/com/streamx/cli/mesh/MeshDefinitionResolverTest.java +++ b/src/test/java/com/streamx/cli/mesh/MeshDefinitionResolverTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.streamx.mesh.model.ServiceMesh; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import java.io.IOException; @@ -20,7 +21,7 @@ class MeshDefinitionResolverTest { @Test void shouldResolveGivenMeshDefinition() throws IOException { // when - var result = uut.resolve(TEST_MESH_PATH); + ServiceMesh result = uut.resolve(TEST_MESH_PATH); // then assertThat(result).isNotNull(); diff --git a/src/test/java/com/streamx/cli/test/BuildExecutableOnce.java b/src/test/java/com/streamx/cli/test/BuildExecutableOnce.java index b2f68845..c8dbf930 100644 --- a/src/test/java/com/streamx/cli/test/BuildExecutableOnce.java +++ b/src/test/java/com/streamx/cli/test/BuildExecutableOnce.java @@ -2,44 +2,67 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -// Build jar or native image before running integration tests +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +// Resolve jar or native image path before running integration tests final class BuildExecutableOnce { + private static final boolean NATIVE = Boolean.getBoolean("native.image"); + private static final Path TARGET = Path.of("target"); private static volatile boolean done; private static volatile boolean success; + private static List resolvedCommand; static void ensureBuilt() { - System.out.println("Building CLI executable before running tests..."); - if (done) { - assertTrue(success, "Maven build failed in a previous run"); + assertTrue(success, "Executable resolution failed in a previous run"); return; } synchronized (BuildExecutableOnce.class) { if (done) { - assertTrue(success, "Maven build failed in a previous run"); + assertTrue(success, "Executable resolution failed in a previous run"); return; } try { - boolean isNativeImage = Boolean.getBoolean("native.image"); - String mvn = System.getProperty("os.name").toLowerCase().contains("win") - ? "mvn.cmd" - : "mvn"; - String[] command = isNativeImage - ? new String[]{mvn, "clean", "package", "-Pnative", "-DskipTests"} - : new String[]{mvn, "clean", "package", "-DskipTests"}; - - int exitCode = new ProcessBuilder(command) - .inheritIO() - .start() - .waitFor(); - - success = exitCode == 0; - } catch (Exception e) { - success = false; + resolvedCommand = resolveExecutablePath(); + success = true; } finally { done = true; } - assertTrue(success, "Maven build failed"); + } + } + + static List getExecutablePath() { + assertTrue(done && success, "ensureBuilt() must be called before getExecutablePath()"); + return resolvedCommand; + } + + private static List resolveExecutablePath() { + if (NATIVE) { + Path executable = findNativeExecutable(); + assertTrue(Files.isExecutable(executable), + "Native executable not found in %s. Run 'mvn package -Pnative -DskipTests' first" + .formatted(TARGET)); + return List.of(executable.toAbsolutePath().toString()); + } else { + Path jar = TARGET.resolve("quarkus-app/quarkus-run.jar"); + assertTrue(jar.toFile().exists(), + "JAR not found at %s. Run 'mvn package -DskipTests' first".formatted(jar)); + return List.of("java", "-jar", jar.toAbsolutePath().toString()); + } + } + + private static Path findNativeExecutable() { + try (Stream files = Files.list(TARGET)) { + return files + .filter(p -> p.getFileName().toString().endsWith("-runner")) + .filter(Files::isExecutable) + .findFirst() + .orElse(TARGET.resolve("*-runner")); + } catch (Exception e) { + return TARGET.resolve("*-runner"); } } diff --git a/src/test/java/com/streamx/cli/test/CliBaseIT.java b/src/test/java/com/streamx/cli/test/CliBaseIT.java index 1c13759e..c8815cb1 100644 --- a/src/test/java/com/streamx/cli/test/CliBaseIT.java +++ b/src/test/java/com/streamx/cli/test/CliBaseIT.java @@ -1,10 +1,16 @@ package com.streamx.cli.test; +import com.streamx.cli.commands.StreamxCommand; +import com.streamx.cli.framework.AbstractCommand; +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.InjectableInstance; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.OutputStream; +import java.io.PrintStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -12,15 +18,18 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import picocli.CommandLine; public abstract class CliBaseIT { - private static final boolean NATIVE = Boolean.getBoolean("native.image"); - private static final Path TARGET = Path.of("target"); private static final long DEFAULT_TIMEOUT_SECONDS = 30; private Process process; + private static boolean isNative() { + return "true".equals(System.getProperty("native.image")); + } + @BeforeEach void resetPublishedEventsBaseline() { MeshAssertions.resetPublishedEventsBaseline(); @@ -28,7 +37,9 @@ void resetPublishedEventsBaseline() { @BeforeAll static void ensureBuilt() { - BuildExecutableOnce.ensureBuilt(); + if (isNative()) { + BuildExecutableOnce.ensureBuilt(); + } } @AfterEach @@ -39,12 +50,15 @@ void cleanupProcess() { } protected ProcessResult execWithStdin(InputStream stdin, String... args) throws Exception { - return execWithStdin(stdin, DEFAULT_TIMEOUT_SECONDS, args); + if (isNative()) { + return execSubprocess(stdin, DEFAULT_TIMEOUT_SECONDS, args); + } + return execInProcess(stdin, args); } protected ProcessResult execWithStdin(String stdin, String... args) throws Exception { return execWithStdin( - new java.io.ByteArrayInputStream(stdin.getBytes(StandardCharsets.UTF_8)), + new ByteArrayInputStream(stdin.getBytes(StandardCharsets.UTF_8)), args ); } @@ -54,29 +68,109 @@ protected ProcessResult execWithStdin( long timeoutSeconds, String... args ) throws Exception { - var command = new ArrayList<>(resolveBaseCommand()); + if (isNative()) { + return execSubprocess(stdin, timeoutSeconds, args); + } + return execInProcess(stdin, args); + } + + protected ProcessResult exec(String... args) throws Exception { + return execWithStdin(InputStream.nullInputStream(), args); + } + + /** In-process execution for JVM mode. */ + private ProcessResult execInProcess(InputStream stdin, String... args) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + + InputStream originalIn = System.in; + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + + try { + System.setIn(stdin); + System.setOut(new PrintStream(out)); + System.setErr(new PrintStream(err)); + + int exitCode = createCommandLine().execute(args); + + return new ProcessResult( + exitCode, + out.toString(StandardCharsets.UTF_8), + err.toString(StandardCharsets.UTF_8) + ); + } finally { + System.setIn(originalIn); + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + /** Creates a CommandLine instance for in-process execution. */ + protected CommandLine createCommandLine() { + ArcContainer container = Arc.container(); + CommandLine cmd = new CommandLine(new StreamxCommand(), new CommandLine.IFactory() { + @Override + public K create(Class cls) throws Exception { + InjectableInstance instance = container.select(cls); + if (instance.isResolvable()) { + return instance.get(); + } + return CommandLine.defaultFactory().create(cls); + } + }); + + cmd.setExecutionStrategy(parseResult -> { + CommandLine.ParseResult pr = parseResult; + while (pr != null) { + if (pr.isUsageHelpRequested() || pr.isVersionHelpRequested()) { + return new CommandLine.RunLast().execute(parseResult); + } + + pr = pr.hasSubcommand() ? pr.subcommand() : null; + } + + Assertions.assertNotNull(parseResult); + List parsed = parseResult.asCommandLineList(); + CommandLine last = parsed.getLast(); + Object command = last.getCommand(); + if (command instanceof AbstractCommand abstractCommand) { + return abstractCommand.execute(); + } + return new CommandLine.RunLast().execute(parseResult); + }); + + return cmd; + } + + /** Sub-process execution for native mode. */ + private ProcessResult execSubprocess( + InputStream stdin, + long timeoutSeconds, + String... args + ) throws Exception { + ArrayList command = new ArrayList<>(BuildExecutableOnce.getExecutablePath()); command.addAll(List.of(args)); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); process = pb.start(); - // Start capturing stdout/stderr BEFORE writing stdin StreamCapture stdoutCapture = captureAndForward(process.getInputStream(), System.out); StreamCapture stderrCapture = captureAndForward(process.getErrorStream(), System.err); - // Write stdin on a separate thread to avoid deadlock Thread stdinWriter = Thread.ofVirtual().start(() -> { try (OutputStream os = process.getOutputStream()) { stdin.transferTo(os); os.flush(); } catch (Exception ignored) { - // Process may have exited early + // ignore } }); boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); - Assertions.assertTrue(finished, "Process timed out after %d seconds".formatted(timeoutSeconds)); + Assertions.assertTrue(finished, + "Process timed out after %d seconds".formatted(timeoutSeconds)); stdinWriter.join(); String stdout = stdoutCapture.join(); @@ -85,19 +179,15 @@ protected ProcessResult execWithStdin( return new ProcessResult(process.exitValue(), stdout, stderr); } - protected ProcessResult exec(String... args) throws Exception { - return execWithStdin(InputStream.nullInputStream(), args); - } - - private record StreamCapture(Thread thread, java.io.ByteArrayOutputStream buffer) { + private record StreamCapture(Thread thread, ByteArrayOutputStream buffer) { String join() throws InterruptedException { thread.join(); return buffer.toString(StandardCharsets.UTF_8); } } - private StreamCapture captureAndForward(InputStream source, java.io.PrintStream target) { - var buffer = new java.io.ByteArrayOutputStream(); + private StreamCapture captureAndForward(InputStream source, PrintStream target) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); Thread thread = Thread.ofVirtual().start(() -> { try { byte[] buf = new byte[1024]; @@ -114,35 +204,6 @@ private StreamCapture captureAndForward(InputStream source, java.io.PrintStream return new StreamCapture(thread, buffer); } - private static List resolveBaseCommand() { - if (NATIVE) { - Path executable = findNativeExecutable(); - Assertions.assertTrue(Files.isExecutable(executable), - "Native executable not found at %s. Run 'mvn package -Pnative -DskipTests' first" - .formatted(executable)); - return List.of(executable.toAbsolutePath().toString()); - } else { - Path jar = TARGET.resolve("quarkus-app/quarkus-run.jar"); - Assertions.assertTrue(jar.toFile().exists(), - "JAR not found at %s. Run 'mvn package -DskipTests' first".formatted(jar)); - return List.of("java", "-jar", jar.toAbsolutePath().toString()); - } - } - - private static Path findNativeExecutable() { - try (var files = Files.list(TARGET)) { - - // TODO - check after native image build will be implemented - return files - .filter(p -> p.getFileName().toString().endsWith("-runner")) - .filter(Files::isExecutable) - .findFirst() - .orElse(TARGET.resolve("*-runner")); - } catch (Exception e) { - return TARGET.resolve("*-runner"); - } - } - public record ProcessResult(int exitCode, String stdout, String stderr) { public void assertSuccess() { diff --git a/src/test/java/com/streamx/cli/test/MeshTestEnv.java b/src/test/java/com/streamx/cli/test/MeshTestEnv.java index 1754b896..3d537386 100644 --- a/src/test/java/com/streamx/cli/test/MeshTestEnv.java +++ b/src/test/java/com/streamx/cli/test/MeshTestEnv.java @@ -9,6 +9,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; +import java.io.PrintStream; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -39,7 +41,7 @@ public class MeshTestEnv { void onStart(@Observes StartupEvent ev) { captureAuthToken(); - var path = Paths.get(meshPath); + Path path = Paths.get(meshPath); meshManager.initializeMesh(path); meshManager.initializeRunMode(path); meshManager.start(); @@ -61,8 +63,8 @@ public String awaitAuthToken() { } private void captureAuthToken() { - var originalOut = System.out; - var interceptor = new java.io.PrintStream(originalOut) { + PrintStream originalOut = System.out; + PrintStream interceptor = new java.io.PrintStream(originalOut) { @Override public void println(String x) { if (x != null && capturedToken == null) { diff --git a/src/test/java/com/streamx/cli/test/annotation/DisabledIfDockerUnavailable.java b/src/test/java/com/streamx/cli/test/annotation/DisabledIfDockerUnavailable.java new file mode 100644 index 00000000..7e8780f1 --- /dev/null +++ b/src/test/java/com/streamx/cli/test/annotation/DisabledIfDockerUnavailable.java @@ -0,0 +1,17 @@ +package com.streamx.cli.test.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Docker doesn't work with macOS arm64 at this moment. +Therefore, we can't run tests which rely on docker engine on this type of CI runner. +At the same time, it would be nice to keep other tests working. +*/ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(DockerAvailableCondition.class) +public @interface DisabledIfDockerUnavailable { +} \ No newline at end of file diff --git a/src/test/java/com/streamx/cli/test/annotation/DockerAvailableCondition.java b/src/test/java/com/streamx/cli/test/annotation/DockerAvailableCondition.java new file mode 100644 index 00000000..e060f524 --- /dev/null +++ b/src/test/java/com/streamx/cli/test/annotation/DockerAvailableCondition.java @@ -0,0 +1,25 @@ +package com.streamx.cli.test.annotation; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class DockerAvailableCondition implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + try { + Process process = new ProcessBuilder("docker", "info") + .redirectErrorStream(true) + .start(); + boolean exited = process.waitFor(5, TimeUnit.SECONDS); + if (exited && process.exitValue() == 0) { + return ConditionEvaluationResult.enabled("Docker is available"); + } + } catch (Exception ignored) { + // ignore + } + return ConditionEvaluationResult.disabled("Docker is not available"); + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index f730cf1d..2c8b600a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -8,3 +8,6 @@ propertySource=classPathProperty string.array.property=stringArrayValueFromFile string.property=stringValueFromFile integer.property=1 + +# Helps to identify container-related issues during tests +quarkus.log.category."com.streamx.runner.docker".level=DEBUG \ No newline at end of file