diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index c2ee81e0..bece83ba 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1 +1 @@ -{"functions-framework-api":"1.1.0","invoker":"1.3.1","function-maven-plugin":"0.11.0"} +{"functions-framework-api":"2.0.1","invoker":"2.0.1","function-maven-plugin":"1.0.1"} diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 45d57ef1..8b137891 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,9 +1 @@ -assign_prs: - - janell-chen - - HKWinterhalter - - kenneth-rosario -assign_issues: - - janell-chen - - HKWinterhalter - - kenneth-rosario diff --git a/.github/release-please-config.json b/.github/release-please-config.json index afc739a2..cfe9f42a 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -70,4 +70,4 @@ ] } } -} \ No newline at end of file +} diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml index 7fe36225..a97dad2f 100644 --- a/.github/release-trigger.yml +++ b/.github/release-trigger.yml @@ -1 +1,2 @@ -enabled: true \ No newline at end of file +enabled: true +multiScmName: functions-framework-java diff --git a/.github/renovate.json b/.github/renovate.json index 7287767f..2e79135d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["group:allNonMajor", "schedule:monthly"], + "ignoreDeps": ["org.apache.maven.plugins:maven-source-plugin"], "packageRules": [ { "description": "Create a PR whenever there is a new major version", diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 2c320a6e..c97377e7 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -14,7 +14,7 @@ on: permissions: read-all jobs: - java11-buildpack-test: + java21-buildpack-test: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: http-builder-source: '/tmp/tests/conformance' @@ -22,9 +22,9 @@ jobs: cloudevent-builder-source: '/tmp/tests/conformance' cloudevent-builder-target: 'com.google.cloud.functions.conformance.CloudEventsConformanceFunction' prerun: 'invoker/conformance/prerun.sh' - builder-runtime: 'java11' - builder-runtime-version: '11' - builder-url: gcr.io/gae-runtimes/buildpacks/google-gae-22/java/builder:latest + builder-runtime: 'java21' + builder-runtime-version: '21' + builder-url: gcr.io/serverless-runtimes/google-22-full/builder/java:latest java17-buildpack-test: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: @@ -35,4 +35,4 @@ jobs: prerun: 'invoker/conformance/prerun.sh' builder-runtime: 'java17' builder-runtime-version: '17' - builder-url: gcr.io/gae-runtimes/buildpacks/google-gae-22/java/builder:latest + builder-url: gcr.io/serverless-runtimes/google-22-full/builder/java:latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d09610e0..841500f7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,7 +14,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest - + permissions: actions: read contents: read @@ -22,13 +22,13 @@ jobs: strategy: fail-fast: false - matrix: + matrix: # Autobuild each of these seperate maven projects working-directory: ['invoker', 'functions-framework-api', 'function-maven-plugin'] steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: disable-sudo: true egress-policy: block @@ -37,16 +37,17 @@ jobs: github.com:443 objects.githubusercontent.com:443 proxy.golang.org:443 + release-assets.githubusercontent.com:443 repo.maven.apache.org:443 storage.googleapis.com:443 uploads.github.com:443 - + - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support languages: java @@ -57,8 +58,6 @@ jobs: # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - - - name: Build run: | (cd functions-framework-api/ && mvn install) @@ -66,6 +65,6 @@ jobs: (cd function-maven-plugin && mvn install) - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: category: ${{ matrix.working-directory }} diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index b946eeeb..091a77a4 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -14,11 +14,12 @@ jobs: strategy: matrix: java: [ - 11.x, + 17.x, + 21.x ] steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: disable-sudo: true egress-policy: block @@ -27,21 +28,22 @@ jobs: github.com:443 objects.githubusercontent.com:443 proxy.golang.org:443 + release-assets.githubusercontent.com:443 repo.maven.apache.org:443 storage.googleapis.com:443 - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: ${{ matrix.java }} distribution: temurin - name: Setup Go - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.21' + go-version: '1.26' - name: Build API with Maven run: (cd functions-framework-api/ && mvn install) @@ -53,7 +55,7 @@ jobs: run: (cd function-maven-plugin/ && mvn install) - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # main + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: functionType: 'http' useBuildpacks: false @@ -61,7 +63,7 @@ jobs: startDelay: 10 - name: Run Typed conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # main + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: functionType: 'http' declarativeType: 'typed' @@ -70,7 +72,7 @@ jobs: startDelay: 10 - name: Run background event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # main + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: functionType: 'legacyevent' useBuildpacks: false @@ -79,7 +81,7 @@ jobs: startDelay: 10 - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # main + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: functionType: 'cloudevent' useBuildpacks: false @@ -88,10 +90,10 @@ jobs: startDelay: 10 - name: Run HTTP concurrency conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # main + uses: GoogleCloudPlatform/functions-framework-conformance/action@86ea75023bd1b906b55d89b64dbdf80b49b850cc # main with: functionType: 'http' useBuildpacks: false validateConcurrency: true cmd: "'mvn -f invoker/conformance/pom.xml function:run -Drun.functionTarget=com.google.cloud.functions.conformance.ConcurrentHttpConformanceFunction'" - startDelay: 10 \ No newline at end of file + startDelay: 10 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 448eb910..2c88f621 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,18 +13,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: disable-sudo: true egress-policy: block allowed-endpoints: > github.com:443 repo.maven.apache.org:443 - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK - uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: - java-version: 11.x + java-version: 17.x distribution: temurin - name: Build API with Maven run: (cd functions-framework-api/ && mvn install) @@ -38,11 +38,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 # v2 minimum required + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # v2 minimum required + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: 21.x + distribution: temurin - name: Run formatter id: formatter uses: axel-op/googlejavaformat-action@dbff853fb823671ec5781365233bf86543b13215 # v3 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d0d20ff2..ba1d718d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,12 +26,13 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: disable-sudo: true egress-policy: block allowed-endpoints: > api.osv.dev:443 + api.scorecard.dev:443 api.securityscorecards.dev:443 auth.docker.io:443 bestpractices.coreinfrastructure.org:443 @@ -42,14 +43,14 @@ jobs: www.bestpractices.dev:443 *.sigstore.dev:443 *.github.com:443 - + - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -61,6 +62,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index d8c12f2a..fd3ffc97 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -13,13 +13,12 @@ jobs: strategy: matrix: java: [ - 11.x, 17.x, - 21-ea + 21.x ] steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: disable-sudo: true egress-policy: block @@ -28,13 +27,13 @@ jobs: repo.maven.apache.org:443 api.adoptium.net:443 *.githubusercontent.com:443 - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: ${{ matrix.java }} distribution: temurin - name: Build with Maven run: (cd functions-framework-api/ && mvn install) - name: Test - run: (cd invoker/ && mvn test) \ No newline at end of file + run: (cd invoker/ && mvn test) diff --git a/.gitignore b/.gitignore index cb8a3b7e..4cd4e4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # Maven target/ +dependency-reduced-pom.xml # Gradle .gradle diff --git a/.kokoro/build.cfg b/.kokoro/build.cfg new file mode 100644 index 00000000..59cdfe4c --- /dev/null +++ b/.kokoro/build.cfg @@ -0,0 +1,24 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +build_file: "functions-framework-java/.kokoro/build.sh" +container_properties { + # Use the full image which has Java, Maven, and gcloud CLI pre-installed + docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/kokoro/ubuntu/ubuntu2204/full:current" +} + +fileset_artifacts { + name: "artifacts" + # We will copy the built jars to this folder in build.sh for signing + artifact_globs: "artifacts/*" + error_if_missing: true + destinations { + store_attestation: true + gcs { + gcs_root_path: "oss-exit-gate-prod-projects-bucket/ff-releases/mavencentral/attestations" + } + } + generate_sbom_from_fileset: true + generate_attestation: true +} diff --git a/.kokoro/build.sh b/.kokoro/build.sh new file mode 100755 index 00000000..597b5cf3 --- /dev/null +++ b/.kokoro/build.sh @@ -0,0 +1,114 @@ +#!/bin/bash +set -euo pipefail + +# The repo is cloned to $KOKORO_ARTIFACTS_DIR/git/functions-framework-java +REPO_DIR="${KOKORO_ARTIFACTS_DIR}/git/functions-framework-java" +cd "${REPO_DIR}" + +# ============================================================================== +# 1. Configure Airlock and AR Credentials +# ============================================================================== +# Get OAuth token from GCE metadata server inside Kokoro VM +MAVEN_TOKEN=$(curl -s "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" -H "Metadata-Flavor: Google" | grep -oP '"access_token":"\K[^"]+') + +# Create a temporary settings.xml to configure Airlock mirror and AR auth +cat > settings.xml < + + + + airlock-mirror + Airlock Maven Central mirror + https://us-maven.pkg.dev/artifact-foundry-prod/maven-3p-trusted + * + + + + + + airlock-mirror + oauth2accesstoken + ${MAVEN_TOKEN} + + + + exit-gate-ar + oauth2accesstoken + ${MAVEN_TOKEN} + + + +EOF + +# ============================================================================== +# 2. Retrieve GPG keys from Secret Manager +# ============================================================================== +GPG_KEYRING="${KOKORO_ARTIFACTS_DIR}/gpg-keyring" +GPG_PASSPHRASE_FILE="${KOKORO_ARTIFACTS_DIR}/gpg-passphrase" + +# Read names from environment variables injected by Louhi +PROJECT_ID="${_LOUHI_SECRET_PROJECT_ID}" +KEYRING_NAME="${_LOUHI_GPG_KEYRING_SECRET_NAME}" +PASSPHRASE_NAME="${_LOUHI_GPG_PASSPHRASE_SECRET_NAME}" + +echo "Fetching secrets from project: ${PROJECT_ID}" +gcloud secrets versions access latest --secret="${KEYRING_NAME}" --project="${PROJECT_ID}" > "${GPG_KEYRING}" +gcloud secrets versions access latest --secret="${PASSPHRASE_NAME}" --project="${PROJECT_ID}" > "${GPG_PASSPHRASE_FILE}" + +export GPG_TTY=$(tty) +export GPG_PASSPHRASE=$(cat "${GPG_PASSPHRASE_FILE}") +export GNUPGHOME=/tmp/gpg +mkdir -p "${GNUPGHOME}" +gpg --batch --import "${GPG_KEYRING}" + +# ============================================================================== +# 3. Build, Sign, and Deploy +# ============================================================================== +# Detect which package to build based on the Louhi trigger tag +if [[ -n "${_LOUHI_REF_NAME:-}" ]]; then + echo "Triggered by Louhi tag: ${_LOUHI_REF_NAME}" + if [[ "${_LOUHI_REF_NAME}" == *functions-framework-api* ]]; then + PACKAGE_DIR="functions-framework-api" + elif [[ "${_LOUHI_REF_NAME}" == *function-maven-plugin* ]]; then + PACKAGE_DIR="function-maven-plugin" + elif [[ "${_LOUHI_REF_NAME}" == *java-function-invoker* ]]; then + PACKAGE_DIR="invoker" + else + echo "Unknown tag format: ${_LOUHI_REF_NAME}. Defaulting to invoker." + PACKAGE_DIR="invoker" + fi +else + # Fallback for manual/non-tag builds (e.g. testing) + echo "No Louhi tag detected. Falling back to KOKORO_JOB_NAME detection." + if [[ $KOKORO_JOB_NAME == *"function-maven-plugin"* ]]; then + PACKAGE_DIR="function-maven-plugin" + elif [[ $KOKORO_JOB_NAME == *"functions-framework-api"* ]]; then + PACKAGE_DIR="functions-framework-api" + else + PACKAGE_DIR="invoker" + fi +fi + +echo "Building package in directory: ${PACKAGE_DIR}" +cd "${PACKAGE_DIR}" + +# Run maven deploy using the temporary settings.xml +# We use altDeploymentRepository to override the deploy target without editing pom.xml +mvn clean deploy -B \ + -P sonatype-oss-release \ + --settings=../settings.xml \ + -DaltDeploymentRepository=exit-gate-ar::https://us-maven.pkg.dev/oss-exit-gate-prod/ff-releases--mavencentral \ + -Dgpg.executable=gpg \ + -Dgpg.passphrase="${GPG_PASSPHRASE}" \ + -Dgpg.homedir="${GNUPGHOME}" + +# ============================================================================== +# 4. Copy artifacts to 'artifacts/' folder for Kokoro Attestation Generation +# ============================================================================== +ARTIFACTS_DIR="${REPO_DIR}/artifacts" +mkdir -p "${ARTIFACTS_DIR}" + +# Copy target jars and poms (excluding test jars) to be captured by build.cfg +find target/ -maxdepth 1 -name "*.jar" -o -name "*.pom" | grep -v "test" | xargs -I {} cp {} "${ARTIFACTS_DIR}/" diff --git a/.kokoro/release.cfg b/.kokoro/release.cfg index f2ee4665..c617e165 100644 --- a/.kokoro/release.cfg +++ b/.kokoro/release.cfg @@ -1,22 +1,23 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + build_file: "functions-framework-java/.kokoro/release.sh" +container_properties { + docker_image: "us-docker.pkg.dev/artifact-foundry-prod/docker-3p-trusted/ubuntu:22.04" +} -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 75669 - keyname: "functions-framework-java-release-bot-sonatype-password" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-pubkeyring" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-keyring" - } - keystore_resource { - keystore_config_id: 70247 - keyname: "maven-gpg-passphrase" +fileset_artifacts { + name: "manifest" + artifact_globs: "manifest.json" + error_if_missing: true + destinations { + store_attestation: false + gcs { + gcs_root_path: "oss-exit-gate-prod-projects-bucket/ff-releases/mavencentral/manifests" + populate_content_type: true } } + generate_sbom_from_fileset: false + generate_attestation: false } diff --git a/.kokoro/release.sh b/.kokoro/release.sh old mode 100644 new mode 100755 index 0a599f31..58b865a6 --- a/.kokoro/release.sh +++ b/.kokoro/release.sh @@ -1,71 +1,10 @@ #!/bin/bash +set -euo pipefail -# Stop execution when any command fails. -set -e +cd "${KOKORO_ARTIFACTS_DIR}" -# Get secrets from keystore and set and environment variables. -setup_environment_secrets() { - export GPG_TTY=$(tty) - export SONATYPE_USERNAME=functions-framework-release-bot - export SONATYPE_PASSWORD=$(cat ${KOKORO_KEYSTORE_DIR}/75669_functions-framework-java-release-bot-sonatype-password) - export GPG_PASSPHRASE=$(cat ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-passphrase) - - # Add the key ring files to $GNUPGHOME to verify the GPG credentials. - export GNUPGHOME=/tmp/gpg - mkdir $GNUPGHOME - mv ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-pubkeyring $GNUPGHOME/pubring.gpg - mv ${KOKORO_KEYSTORE_DIR}/70247_maven-gpg-keyring $GNUPGHOME/secring.gpg - gpg -k -} - -create_settings_xml_file() { - echo " - - - - true - - - ${GPG_PASSPHRASE} - - - - - - sonatype-nexus-staging - ${SONATYPE_USERNAME} - ${SONATYPE_PASSWORD} - - - sonatype-nexus-snapshots - ${SONATYPE_USERNAME} - ${SONATYPE_PASSWORD} - - -" > $1 +cat > manifest.json <<'EOF' +{ + "publish_all": true } - -setup_environment_secrets - -# Pick the right package to release based on the Kokoro job name. -cd ${KOKORO_ARTIFACTS_DIR}/github/functions-framework-java -create_settings_xml_file "settings.xml" -echo "KOKORO_JOB_NAME=${KOKORO_JOB_NAME}" -if [[ $KOKORO_JOB_NAME == *"function-maven-plugin"* ]]; then - cd function-maven-plugin -elif [[ $KOKORO_JOB_NAME == *"functions-framework-api"* ]]; then - cd functions-framework-api -else - cd invoker -fi -echo "pwd=$(pwd)" - -# Make sure `JAVA_HOME` is set and using jdk11. -export JAVA_HOME=/usr/lib/jvm/java-1.11.0-openjdk-amd64 -echo "JAVA_HOME=$JAVA_HOME" -mvn clean deploy -B \ - -P sonatype-oss-release \ - --settings=../settings.xml \ - -Dgpg.executable=gpg \ - -Dgpg.passphrase=${GPG_PASSPHRASE} \ - -Dgpg.homedir=${GNUPGHOME} +EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7e4114c..5d18935a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,15 +38,15 @@ This project is divided into multiple packages, primarily: - `function-maven-plugin` - The Maven plugin for building functions - `conformance` - A set of functions used for conformance testing -### Setup JDK 11 / 17 +### Setup JDK 17 / 21 -Install JDK 11 and 17. One way to install these is through [SDK man](https://sdkman.io/). +Install JDK 17 and 21. One way to install these is through [SDK man](https://sdkman.io/). ```sh -sdk install java 11.0.2-open sdk install java 17-open +sdk install java 21-open sdk use java 17-open -sdk use java 11.0.2-open +sdk use java 21-open ``` Verify Java version with: diff --git a/README.md b/README.md index e4c543a4..850b806c 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,13 @@ ![Security Scorecard](https://api.securityscorecards.dev/projects/github.com/GoogleCloudPlatform/functions-framework-java/badge) An open source FaaS (Function as a service) framework for writing portable -Java functions -- brought to you by the Google Cloud Functions team. +Java functions. The Functions Framework lets you write lightweight functions that run in many different environments, including: -* [Google Cloud Functions](https://cloud.google.com/functions/) +* [Google Cloud Run functions](https://cloud.google.com/functions/) * Your local development machine -* [Cloud Run](https://cloud.google.com/run/) and [Cloud Run for Anthos](https://cloud.google.com/anthos/run/) * [Knative](https://github.com/knative/)-based environments ## Installation @@ -41,7 +40,7 @@ that supports Maven to create the Maven project. Add this dependency in the com.google.cloud.functions functions-framework-api - 1.0.4 + 1.1.2 provided ``` @@ -51,7 +50,7 @@ Framework dependency in your `build.gradle` project file as follows: ```groovy dependencies { - implementation 'com.google.cloud.functions:functions-framework-api:1.0.4' + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' } ``` @@ -188,7 +187,7 @@ Copy the Functions Framework jar to a local location like this: ```sh mvn dependency:copy \ - -Dartifact='com.google.cloud.functions.invoker:java-function-invoker:1.1.1' \ + -Dartifact='com.google.cloud.functions.invoker:java-function-invoker:1.3.2' \ -DoutputDirectory=. ``` @@ -196,7 +195,7 @@ In this example we use the current directory `.` but you can specify any other directory to copy to. Then run your function: ```sh -java -jar java-function-invoker-1.1.1 \ +java -jar java-function-invoker-1.3.2 \ --classpath myfunction.jar \ --target com.example.HelloWorld ``` @@ -215,8 +214,8 @@ configurations { } dependencies { - implementation 'com.google.cloud.functions:functions-framework-api:1.0.4' - invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.1.1' + implementation 'com.google.cloud.functions:functions-framework-api:1.1.2' + invoker 'com.google.cloud.functions.invoker:java-function-invoker:1.3.2' } tasks.register("runFunction", JavaExec) { @@ -289,7 +288,7 @@ Framework directly, you must use `--classpath` to indicate how to find the code and its dependencies. For example: ``` -java -jar java-function-invoker-1.1.1 \ +java -jar java-function-invoker-1.3.2 \ --classpath 'myfunction.jar:/some/directory:/some/library/*' \ --target com.example.HelloWorld ``` diff --git a/function-maven-plugin/CHANGELOG.md b/function-maven-plugin/CHANGELOG.md index fc0c67b1..6004a955 100644 --- a/function-maven-plugin/CHANGELOG.md +++ b/function-maven-plugin/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [1.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v1.0.0...function-maven-plugin-v1.0.1) (2026-04-10) + + +### Dependencies + +* update Functions Framework Invoker dependency to 2.0.1 ([#385](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/385)) ([9e69efe](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9e69efe14b779ab21a4f85e54c51283d0ec1a2ec)) + +## [1.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.2...function-maven-plugin-v1.0.0) (2025-11-06) + + +### ⚠ BREAKING CHANGES + +* update functions-framework-invoker dependency version to 2.0.0 for ff maven plugin ([#368](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/368)) +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + + +### Miscellaneous Chores + +* update functions-framework-invoker dependency version to 2.0.0 for ff maven plugin ([#368](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/368)) ([9dd6008](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9dd6008b254d7188a675e40f6ff234b82b07a11b)) + +## [0.11.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.1...function-maven-plugin-v0.11.2) (2025-10-21) + + +### Bug Fixes + +* several minor fixes ([#341](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/341)) ([e152e0d](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/e152e0d697c1a3f4a90a6480347b4ddf0b73f3e3)) +* update dependency com.google.cloud.functions.invoker to 1.4.3 ([#354](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/354)) ([0e24d42](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/0e24d42573d104f8b73921513f6f296ada4a255c)) + +## [0.11.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.11.0...function-maven-plugin-v0.11.1) (2024-11-27) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + ## [0.11.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/function-maven-plugin-v0.10.1...function-maven-plugin-v0.11.0) (2023-05-31) diff --git a/function-maven-plugin/pom.xml b/function-maven-plugin/pom.xml index b37754e6..6dca952c 100644 --- a/function-maven-plugin/pom.xml +++ b/function-maven-plugin/pom.xml @@ -10,11 +10,11 @@ com.google.cloud.functions function-maven-plugin maven-plugin - 0.11.1-SNAPSHOT + 1.0.2-SNAPSHOT Functions Framework Plugin A Maven plugin that allows functions to be deployed, and to be run locally using the Java Functions Framework. - http://maven.apache.org + https://github.com/GoogleCloudPlatform/functions-framework-java http://github.com/GoogleCloudPlatform/functions-framework-java @@ -23,6 +23,21 @@ HEAD + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + Apache License, Version 2.0 @@ -32,8 +47,8 @@ - 11 - 11 + 17 + 17 @@ -41,37 +56,39 @@ org.apache.maven maven-plugin-api - 3.9.6 + 3.9.14 + provided org.apache.maven maven-core - 3.9.6 + 3.9.14 + provided org.apache.maven.plugin-tools maven-plugin-annotations - 3.11.0 + 3.15.2 provided com.google.cloud.functions.invoker java-function-invoker - 1.3.1 + 2.0.1 com.google.cloud.tools appengine-maven-plugin - 2.6.0 + 2.8.7 jar com.google.truth truth - 1.2.0 + 1.4.5 test @@ -87,7 +104,7 @@ org.apache.maven.plugins maven-plugin-plugin - 3.11.0 + 3.15.2 help-goal @@ -99,18 +116,6 @@ - - - sonatype-nexus-snapshots - Sonatype Nexus Snapshots - https://oss.sonatype.org/content/repositories/snapshots/ - - - sonatype-nexus-staging - Nexus Release Repository - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - sonatype-oss-release @@ -119,7 +124,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.0 + 3.2.1 attach-sources @@ -132,7 +137,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.6.3 + 3.12.0 attach-javadocs @@ -145,7 +150,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + 3.2.8 sign-artifacts @@ -157,14 +162,13 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - true + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ diff --git a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java index 35dab3f8..08bff714 100644 --- a/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java +++ b/function-maven-plugin/src/main/java/com/google/cloud/functions/plugin/DeployFunction.java @@ -63,9 +63,8 @@ public class DeployFunction extends CloudSdkMojo { */ @Parameter( alias = "deploy.allowunauthenticated", - property = "function.deploy.allowunauthenticated", - defaultValue = "false") - boolean allowUnauthenticated; + property = "function.deploy.allowunauthenticated") + Boolean allowUnauthenticated; /** * Name of a Google Cloud Function (as defined in source code) that will be executed. Defaults to @@ -99,13 +98,13 @@ public class DeployFunction extends CloudSdkMojo { * Runtime in which to run the function. * *

Required when deploying a new function; optional when updating an existing function. Default - * to Java11. + * to Java17. */ @Parameter( alias = "deploy.runtime", - defaultValue = "java11", + defaultValue = "java17", property = "function.deploy.runtime") - String runtime = "java11"; + String runtime = "java17"; /** * The email address of the IAM service account associated with the function at runtime. The @@ -314,8 +313,12 @@ public List getCommands() { if (triggerEvent != null) { commands.add("--trigger-event=" + triggerEvent); } - if (allowUnauthenticated) { - commands.add("--allow-unauthenticated"); + if (allowUnauthenticated != null) { + if (allowUnauthenticated) { + commands.add("--allow-unauthenticated"); + } else { + commands.add("--no-allow-unauthenticated"); + } } if (functionTarget != null) { commands.add("--entry-point=" + functionTarget); @@ -371,6 +374,8 @@ public List getCommands() { if (projectId != null) { commands.add("--project=" + projectId); } + + commands.add("--quiet"); return Collections.unmodifiableList(commands); } @@ -382,7 +387,9 @@ public void execute() throws MojoExecutionException { System.out.println("Executing Cloud SDK command: gcloud " + String.join(" ", params)); gcloud.runCommand(params); } catch (CloudSdkNotFoundException | IOException | ProcessHandlerException ex) { - Logger.getLogger(DeployFunction.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(DeployFunction.class.getName()) + .log(Level.SEVERE, "Function deployment failed", ex); + throw new MojoExecutionException("Function deployment failed", ex); } } } diff --git a/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java b/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java index e5a2913d..6f107cff 100644 --- a/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java +++ b/function-maven-plugin/src/test/java/com/google/cloud/functions/plugin/DeployFunctionTest.java @@ -53,7 +53,8 @@ public void testDeployFunctionCommandLine() { "--env-vars-file=myfile", "--set-build-env-vars=env1=a,env2=b", "--build-env-vars-file=myfile2", - "--runtime=java11"); + "--runtime=java17", + "--quiet"); assertThat(mojo.getCommands()).isEqualTo(expected); } } diff --git a/functions-framework-api/CHANGELOG.md b/functions-framework-api/CHANGELOG.md index 1ab28e74..c2999393 100644 --- a/functions-framework-api/CHANGELOG.md +++ b/functions-framework-api/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## [2.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v2.0.0...functions-framework-api-v2.0.1) (2026-04-10) + + +### Bug Fixes + +* update CloudEvents dependencyFixes [#378](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/378) ([#379](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/379)) ([de983d9](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/de983d907ebba9fba6809e816f6c70f207d6c252)) + +## [2.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.4...functions-framework-api-v2.0.0) (2025-11-05) + + +### ⚠ BREAKING CHANGES + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + +## [1.1.4](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.3...functions-framework-api-v1.1.4) (2024-11-22) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + +## [1.1.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.2...functions-framework-api-v1.1.3) (2024-10-05) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#297](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/297)) ([8f1fd84](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/8f1fd84ca4cc43b2e93b66fe160f78a868b55ffe)) + +## [1.1.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.1...functions-framework-api-v1.1.2) (2024-09-27) + + +### Bug Fixes + +* use a version of maven-source-plugin that's available in mavencentral ([#288](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/288)) ([f8c1d57](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f8c1d575660312101532a1f579c0492593248f37)) + +## [1.1.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.1.0...functions-framework-api-v1.1.1) (2024-06-27) + + +### Bug Fixes + +* release 1.1.1; this updates transitive dependencies on jackson ([#277](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/277)) ([7e4ca5d](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/7e4ca5d15d5b200787b999f82da6d6cd1cbd4b7e)) + ## [1.1.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/functions-framework-api-v1.0.4...functions-framework-api-v1.1.0) (2023-05-31) diff --git a/functions-framework-api/pom.xml b/functions-framework-api/pom.xml index 01e9d22d..250d5c2b 100644 --- a/functions-framework-api/pom.xml +++ b/functions-framework-api/pom.xml @@ -24,12 +24,15 @@ com.google.cloud.functions functions-framework-api - 1.1.1-SNAPSHOT + 2.0.2-SNAPSHOT + Functions Framework Java API + An open source FaaS (Function as a service) framework for writing portable Java functions. + https://github.com/GoogleCloudPlatform/functions-framework-java UTF-8 - 3.12.1 - 3.6.3 + 3.15.0 + 3.12.0 5.3.2 @@ -41,6 +44,21 @@ + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + scm:git:https://github.com/GoogleCloudPlatform/functions-framework-java.git scm:git:git@github.com:GoogleCloudPlatform/functions-framework-java.git @@ -52,7 +70,7 @@ io.cloudevents cloudevents-api - 2.5.0 + 4.0.2 @@ -62,8 +80,8 @@ maven-compiler-plugin ${maven-compiler-plugin.version} - 11 - 11 + 17 + 17 @@ -73,7 +91,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.0 + 3.2.1 attach-sources @@ -86,7 +104,7 @@ org.apache.maven.plugins maven-release-plugin - 3.0.1 + 3.3.1 default @@ -131,18 +149,6 @@ - - - sonatype-nexus-snapshots - Sonatype Nexus Snapshots - https://oss.sonatype.org/content/repositories/snapshots/ - - - sonatype-nexus-staging - Nexus Release Repository - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - sonatype-oss-release @@ -151,7 +157,7 @@ org.apache.maven.plugins maven-source-plugin - 3.6.0 + 3.2.1 attach-sources @@ -177,7 +183,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + 3.2.8 sign-artifacts @@ -189,14 +195,13 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - true + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ diff --git a/invoker/CHANGELOG.md b/invoker/CHANGELOG.md index 2800c114..87db009f 100644 --- a/invoker/CHANGELOG.md +++ b/invoker/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## [2.0.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v2.0.0...java-function-invoker-v2.0.1) (2026-04-10) + + +### Dependencies + +* update Functions Framework API dependency to 2.0.1 ([#382](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/382)) ([a453a31](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/a453a315f1e6abd0720929f451bb6f3aec1aa498)) + +## [2.0.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.3...java-function-invoker-v2.0.0) (2025-11-06) + + +### ⚠ BREAKING CHANGES + +* update functions-framework-api dependency to 2.0.0 ([#365](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/365)) +* **implementation:** use Java 17 or above, as required by Eclipse Jetty-12. +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) + +### Features + +* remove java11 support and expand java21 test coverage ([#356](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/356)) ([c1f27d2](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/c1f27d289e3b9da2ec936fb4d2197f42a2eaa983)) + + +### Miscellaneous Chores + +* **implementation:** use Jetty-12.1 core without servlets ([#333](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/333)) ([e23f98f](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/e23f98f2dc7cbcdbd036a46423c99f82bddd80bc)) +* update functions-framework-api dependency to 2.0.0 ([#365](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/365)) ([f351c1a](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/f351c1ade94a08bc4116b40e2d343e1b5d9a6db6)) + +## [1.4.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.2...java-function-invoker-v1.4.3) (2025-10-20) + + +### Bug Fixes + +* add autovalue plugin config to invoker/core/pom.xml to fix local development ([#339](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/339)) ([4203279](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/420327967b96b4aa66c0edd6753a9d6db781478d)) + +## [1.4.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.1...java-function-invoker-v1.4.2) (2025-10-20) + + +### Bug Fixes + +* add autovalue plugin config to invoker/core/pom.xml to fix local development ([#339](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/339)) ([4203279](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/420327967b96b4aa66c0edd6753a9d6db781478d)) + +## [1.4.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.4.0...java-function-invoker-v1.4.1) (2025-03-07) + + +### Bug Fixes + +* correct Cloud Event retry functionality ([#326](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/326)) ([9899a67](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9899a67a9a8cb6ebb27a92cccb740e7e23d48578)) + +## [1.4.0](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.3...java-function-invoker-v1.4.0) (2025-02-12) + + +### Features + +* Add execution id logging to uniquely identify request logs ([#319](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/319)) ([5ef5317](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/5ef53174b6cdbc644336121bc19bab6c4b90892d)) + +## [1.3.3](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.2...java-function-invoker-v1.3.3) (2024-11-27) + + +### Bug Fixes + +* revert maven-source-plugin to 3.2.1 ([#303](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/303)) ([2db9a2b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/2db9a2bec6ba93e7954e68c2301c5fc2fcc032d8)) + +## [1.3.2](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.1...java-function-invoker-v1.3.2) (2024-09-18) + + +### Bug Fixes + +* avoid executing function when /favicon.ico or /robots.txt is called ([#226](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/226)) ([fca8676](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/fca867667db593699193da01b69a4cca7ca48fc8)) +* server times out when specified by CLOUD_RUN_TIMEOUT_SECONDS ([#275](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/275)) ([9e91f57](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9e91f57b12d73c655e3d7e226d21d54ccec32b73)) +* set Thread Context ClassLoader correctly when invoking handler constructor ([#239](https://github.com/GoogleCloudPlatform/functions-framework-java/issues/239)) ([9f7155b](https://github.com/GoogleCloudPlatform/functions-framework-java/commit/9f7155b77574ec980ecf9e6dffbd2ee0398db8a7)) + ## [1.3.1](https://github.com/GoogleCloudPlatform/functions-framework-java/compare/java-function-invoker-v1.3.0...java-function-invoker-v1.3.1) (2023-09-13) diff --git a/invoker/conformance/pom.xml b/invoker/conformance/pom.xml index 56dfcf68..331f08d1 100644 --- a/invoker/conformance/pom.xml +++ b/invoker/conformance/pom.xml @@ -4,46 +4,46 @@ java-function-invoker-parent com.google.cloud.functions.invoker - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker conformance - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT GCF Confromance Tests A GCF project used to validate conformance to the Functions Framework contract using the Functions Framework Conformance tools. - https://github.com/GoogleCloudPlatform/functions-framework-conformance + https://github.com/GoogleCloudPlatform/functions-framework-java UTF-8 - 11 - 11 + 17 + 17 com.google.cloud.functions functions-framework-api - 1.1.0 + 2.0.0 com.google.code.gson gson - 2.10.1 + 2.13.2 io.cloudevents cloudevents-core - 2.5.0 + 4.0.1 io.cloudevents cloudevents-json-jackson - 2.5.0 + 4.0.1 @@ -53,9 +53,9 @@ com.google.cloud.functions function-maven-plugin - 0.11.1-SNAPSHOT + 1.0.0 - \ No newline at end of file + diff --git a/invoker/core/pom.xml b/invoker/core/pom.xml index c1501eb6..cd8984ba 100644 --- a/invoker/core/pom.xml +++ b/invoker/core/pom.xml @@ -4,25 +4,27 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT GCF Java Invoker Application that invokes a GCF Java function. This application is a complete HTTP server that interprets incoming HTTP requests appropriately and forwards them to the function code. + https://github.com/GoogleCloudPlatform/functions-framework-java UTF-8 5.3.2 - 11 - 11 - 2.5.0 + 17 + 17 + 4.0.1 + 12.1.8 @@ -44,12 +46,7 @@ com.google.cloud.functions functions-framework-api - 1.1.0 - - - javax.servlet - javax.servlet-api - 4.0.1 + 2.0.0 io.cloudevents @@ -69,7 +66,7 @@ com.google.code.gson gson - 2.10.1 + 2.13.2 com.ryanharter.auto.value @@ -86,24 +83,24 @@ com.google.auto.value auto-value - 1.10.4 + 1.11.1 provided com.google.auto.value auto-value-annotations - 1.10.4 + 1.11.1 provided org.eclipse.jetty - jetty-servlet - 9.4.53.v20231009 + jetty-server + ${jetty.version} - org.eclipse.jetty - jetty-server - 9.4.53.v20231009 + org.slf4j + slf4j-jdk14 + 2.0.17 com.beust @@ -115,14 +112,14 @@ com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT test-jar test org.mockito mockito-core - 5.7.0 + 5.23.0 test @@ -134,33 +131,51 @@ com.google.re2j re2j - 1.7 + 1.8 com.google.truth truth - 1.2.0 + 1.4.5 test com.google.truth.extensions truth-java8-extension - 1.2.0 + 1.4.5 test org.eclipse.jetty jetty-client - 9.4.53.v20231009 + ${jetty.version} test + + maven-compiler-plugin + 3.15.0 + + + + com.google.auto.value + auto-value + 1.11.1 + + + com.ryanharter.auto.value + auto-value-gson + 1.3.1 + + + + maven-jar-plugin - 3.3.0 + 3.5.0 @@ -174,7 +189,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.5.1 + 3.6.2 package diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java index 98b9bc8a..097b9a67 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java @@ -21,6 +21,7 @@ import com.google.cloud.functions.CloudEventsFunction; import com.google.cloud.functions.Context; import com.google.cloud.functions.RawBackgroundFunction; +import com.google.cloud.functions.invoker.gcf.ExecutionIdUtil; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; @@ -29,28 +30,36 @@ import io.cloudevents.http.HttpMessageFactory; import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.io.Reader; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.TreeMap; import java.util.logging.Level; import java.util.logging.Logger; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; /** Executes the user's background function. */ -public final class BackgroundFunctionExecutor extends HttpServlet { +public final class BackgroundFunctionExecutor extends Handler.Abstract { private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); private final FunctionExecutor functionExecutor; + private final ExecutionIdUtil executionIdUtil = new ExecutionIdUtil(); private BackgroundFunctionExecutor(FunctionExecutor functionExecutor) { this.functionExecutor = functionExecutor; @@ -175,8 +184,13 @@ static Optional backgroundFunctionTypeArgument( .findFirst(); } - private static Event parseLegacyEvent(HttpServletRequest req) throws IOException { - try (BufferedReader bodyReader = req.getReader()) { + private static Event parseLegacyEvent(Request req) throws IOException { + try (BufferedReader bodyReader = + new BufferedReader( + new InputStreamReader( + Content.Source.asInputStream(req), + Objects.requireNonNullElse( + Request.getCharset(req), StandardCharsets.ISO_8859_1)))) { return parseLegacyEvent(bodyReader); } } @@ -223,7 +237,7 @@ private static Context contextFromCloudEvent(CloudEvent cloudEvent) { * for the various triggers. CloudEvents are ones that follow the standards defined by cloudevents.io. * - * @param the type to be used in the {@link Unmarshallers} call when + * @param the type to be used in the {code Unmarshallers} call when * unmarshalling this event, if it is a CloudEvent. */ private abstract static class FunctionExecutor { @@ -320,20 +334,25 @@ void serviceCloudEvent(CloudEvent cloudEvent) throws Exception { /** Executes the user's background function. This can handle all HTTP methods. */ @Override - public void service(HttpServletRequest req, HttpServletResponse res) throws IOException { - String contentType = req.getContentType(); + public boolean handle(Request req, Response res, Callback callback) throws Exception { + String contentType = req.getHeaders().get(HttpHeader.CONTENT_TYPE); try { + executionIdUtil.storeExecutionId(req); if ((contentType != null && contentType.startsWith("application/cloudevents+json")) - || req.getHeader("ce-specversion") != null) { + || req.getHeaders().get("ce-specversion") != null) { serviceCloudEvent(req); } else { serviceLegacyEvent(req); } - res.setStatus(HttpServletResponse.SC_OK); + res.setStatus(HttpStatus.OK_200); + callback.succeeded(); } catch (Throwable t) { - res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); logger.log(Level.SEVERE, "Failed to execute " + functionExecutor.functionName(), t); + Response.writeError(req, res, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null); + } finally { + executionIdUtil.removeExecutionId(); } + return true; } private enum CloudEventKind { @@ -347,10 +366,14 @@ private enum CloudEventKind { * @param a fake type parameter, which corresponds to the type parameter of {@link * FunctionExecutor}. */ - private void serviceCloudEvent(HttpServletRequest req) throws Exception { + private void serviceCloudEvent(Request req) throws Exception { @SuppressWarnings("unchecked") FunctionExecutor executor = (FunctionExecutor) functionExecutor; - byte[] body = req.getInputStream().readAllBytes(); + + // Read the entire request body into a byte array. + // TODO: this method is deprecated for removal, use the method introduced by + // https://github.com/jetty/jetty.project/pull/13939 when it is released. + byte[] body = Content.Source.asByteArrayAsync(req, -1).get(); MessageReader reader = HttpMessageFactory.createReaderFromMultimap(headerMap(req), body); // It's important not to set the context ClassLoader earlier, because MessageUtils will use // ServiceLoader.load(EventFormat.class) to find a handler to deserialize a binary CloudEvent @@ -364,17 +387,17 @@ private void serviceCloudEvent(HttpServletRequest req) throws Exce // https://github.com/cloudevents/sdk-java/pull/259. } - private static Map> headerMap(HttpServletRequest req) { + private static Map> headerMap(Request req) { Map> headerMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (String header : Collections.list(req.getHeaderNames())) { - for (String value : Collections.list(req.getHeaders(header))) { - headerMap.computeIfAbsent(header, unused -> new ArrayList<>()).add(value); - } + for (HttpField field : req.getHeaders()) { + headerMap + .computeIfAbsent(field.getName(), unused -> new ArrayList<>()) + .addAll(field.getValueList()); } return headerMap; } - private void serviceLegacyEvent(HttpServletRequest req) throws Exception { + private void serviceLegacyEvent(Request req) throws Exception { Event event = parseLegacyEvent(req); runWithContextClassLoader(() -> functionExecutor.serviceLegacyEvent(event)); } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java index 401e22a2..b414f110 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java @@ -15,19 +15,23 @@ package com.google.cloud.functions.invoker; import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.invoker.gcf.ExecutionIdUtil; import com.google.cloud.functions.invoker.http.HttpRequestImpl; import com.google.cloud.functions.invoker.http.HttpResponseImpl; import java.util.logging.Level; import java.util.logging.Logger; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; /** Executes the user's method. */ -public class HttpFunctionExecutor extends HttpServlet { +public class HttpFunctionExecutor extends Handler.Abstract { private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); private final HttpFunction function; + private final ExecutionIdUtil executionIdUtil = new ExecutionIdUtil(); private HttpFunctionExecutor(HttpFunction function) { this.function = function; @@ -63,19 +67,23 @@ public static HttpFunctionExecutor forClass(Class functionClass) { /** Executes the user's method, can handle all HTTP type methods. */ @Override - public void service(HttpServletRequest req, HttpServletResponse res) { - HttpRequestImpl reqImpl = new HttpRequestImpl(req); - HttpResponseImpl respImpl = new HttpResponseImpl(res); + public boolean handle(Request request, Response response, Callback callback) throws Exception { + + HttpRequestImpl reqImpl = new HttpRequestImpl(request); + HttpResponseImpl respImpl = new HttpResponseImpl(response); ClassLoader oldContextLoader = Thread.currentThread().getContextClassLoader(); try { + executionIdUtil.storeExecutionId(request); Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader()); function.service(reqImpl, respImpl); + respImpl.close(callback); } catch (Throwable t) { logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); - res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + Response.writeError(request, response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null); } finally { Thread.currentThread().setContextClassLoader(oldContextLoader); - respImpl.flush(); + executionIdUtil.removeExecutionId(); } + return true; } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java index a6edfc32..63418705 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java @@ -15,11 +15,13 @@ import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; -public class TypedFunctionExecutor extends HttpServlet { +public class TypedFunctionExecutor extends Handler.Abstract { private static final String APPLY_METHOD = "apply"; private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); @@ -94,7 +96,7 @@ static Optional handlerTypeArgument(Class> f /** Executes the user's method, can handle all HTTP type methods. */ @Override - public void service(HttpServletRequest req, HttpServletResponse res) { + public boolean handle(Request req, Response res, Callback callback) throws Exception { HttpRequestImpl reqImpl = new HttpRequestImpl(req); HttpResponseImpl resImpl = new HttpResponseImpl(res); ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader(); @@ -102,10 +104,13 @@ public void service(HttpServletRequest req, HttpServletResponse res) { try { Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader()); handleRequest(reqImpl, resImpl); + resImpl.close(callback); + } catch (Throwable t) { + Response.writeError(req, res, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, null, t); } finally { Thread.currentThread().setContextClassLoader(oldContextClassLoader); - resImpl.flush(); } + return true; } private void handleRequest(HttpRequest req, HttpResponse res) { @@ -114,7 +119,7 @@ private void handleRequest(HttpRequest req, HttpResponse res) { reqObj = format.deserialize(req, argType); } catch (Throwable t) { logger.log(Level.SEVERE, "Failed to parse request for " + function.getClass().getName(), t); - res.setStatusCode(HttpServletResponse.SC_BAD_REQUEST); + res.setStatusCode(HttpStatus.BAD_REQUEST_400); return; } @@ -123,7 +128,7 @@ private void handleRequest(HttpRequest req, HttpResponse res) { resObj = function.apply(reqObj); } catch (Throwable t) { logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); - res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + res.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR_500); return; } @@ -132,7 +137,7 @@ private void handleRequest(HttpRequest req, HttpResponse res) { } catch (Throwable t) { logger.log( Level.SEVERE, "Failed to serialize response for " + function.getClass().getName(), t); - res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + res.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR_500); return; } } @@ -147,7 +152,7 @@ private static class GsonWireFormat implements TypedFunction.WireFormat { @Override public void serialize(Object object, HttpResponse response) throws Exception { if (object == null) { - response.setStatusCode(HttpServletResponse.SC_NO_CONTENT); + response.setStatusCode(HttpStatus.NO_CONTENT_204); return; } try (BufferedWriter bodyWriter = response.getWriter()) { diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java new file mode 100644 index 00000000..becf2c5c --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/ExecutionIdUtil.java @@ -0,0 +1,63 @@ +package com.google.cloud.functions.invoker.gcf; + +import java.util.Base64; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Handler; +import java.util.logging.Logger; +import org.eclipse.jetty.server.Request; + +/** + * A helper class that either fetches a unique execution id from request HTTP headers or generates a + * random id. + */ +public final class ExecutionIdUtil { + private static final Logger rootLogger = Logger.getLogger(""); + private static final int EXECUTION_ID_LENGTH = 12; + private static final String EXECUTION_ID_HTTP_HEADER = "HTTP_FUNCTION_EXECUTION_ID"; + private static final String LOG_EXECUTION_ID_ENV_NAME = "LOG_EXECUTION_ID"; + + private final Random random = ThreadLocalRandom.current(); + + /** + * Add mapping to root logger from current thread id to execution id. This mapping will be used to + * append the execution id to log lines. + */ + public void storeExecutionId(Request request) { + if (!executionIdLoggingEnabled()) { + return; + } + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof JsonLogHandler) { + String id = getOrGenerateExecutionId(request); + ((JsonLogHandler) handler).addExecutionId(Thread.currentThread().getId(), id); + } + } + } + + /** Remove mapping from curent thread to request execution id */ + public void removeExecutionId() { + if (!executionIdLoggingEnabled()) { + return; + } + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof JsonLogHandler) { + ((JsonLogHandler) handler).removeExecutionId(Thread.currentThread().getId()); + } + } + } + + private String getOrGenerateExecutionId(Request request) { + String executionId = request.getHeaders().get(EXECUTION_ID_HTTP_HEADER); + if (executionId == null) { + byte[] array = new byte[EXECUTION_ID_LENGTH]; + random.nextBytes(array); + executionId = Base64.getEncoder().encodeToString(array); + } + return executionId; + } + + private boolean executionIdLoggingEnabled() { + return Boolean.parseBoolean(System.getenv().getOrDefault(LOG_EXECUTION_ID_ENV_NAME, "false")); + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java index 9c94b92a..51aad21a 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/gcf/JsonLogHandler.java @@ -5,6 +5,8 @@ import java.io.StringWriter; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -15,6 +17,7 @@ */ public final class JsonLogHandler extends Handler { private static final String SOURCE_LOCATION_KEY = "\"logging.googleapis.com/sourceLocation\": "; + private static final String LOG_EXECUTION_ID_ENV_NAME = "LOG_EXECUTION_ID"; private static final String DEBUG = "DEBUG"; private static final String INFO = "INFO"; @@ -24,6 +27,14 @@ public final class JsonLogHandler extends Handler { private final PrintStream out; private final boolean closePrintStreamOnClose; + // This map is used to track execution id for currently running Jetty requests. Mapping thread + // id to request works because of an implementation detail of Jetty thread pool handling. + // Jetty worker threads completely handle a request before beginning work on a new request. + // NOTE: Store thread id as a string to avoid comparison failures between int and long. + // + // Jetty Documentation (https://jetty.org/docs/jetty/10/programming-guide/arch/threads.html) + private static final ConcurrentMap executionIdByThreadMap = + new ConcurrentHashMap<>(); public JsonLogHandler(PrintStream out, boolean closePrintStreamOnClose) { this.out = out; @@ -38,6 +49,7 @@ public void publish(LogRecord record) { StringBuilder json = new StringBuilder("{"); appendSeverity(json, record); appendSourceLocation(json, record); + appendExecutionId(json, record); appendMessage(json, record); // must be last, see appendMessage json.append("}"); // We must output the log all at once (should only call println once per call to publish) @@ -96,6 +108,14 @@ private static void appendSourceLocation(StringBuilder json, LogRecord record) { json.append(SOURCE_LOCATION_KEY).append("{").append(String.join(", ", entries)).append("}, "); } + private void appendExecutionId(StringBuilder json, LogRecord record) { + if (executionIdLoggingEnabled()) { + json.append("\"execution_id\": \"") + .append(executionIdByThreadMap.get(Integer.toString(record.getThreadID()))) + .append("\", "); + } + } + private static String escapeString(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); } @@ -117,4 +137,16 @@ public void close() throws SecurityException { out.close(); } } + + public void addExecutionId(long threadId, String executionId) { + executionIdByThreadMap.put(Long.toString(threadId), executionId); + } + + public void removeExecutionId(long threadId) { + executionIdByThreadMap.remove(Long.toString(threadId)); + } + + private boolean executionIdLoggingEnabled() { + return Boolean.parseBoolean(System.getenv().getOrDefault(LOG_EXECUTION_ID_ENV_NAME, "false")); + } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java index 2119645a..31ee4ac6 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpRequestImpl.java @@ -14,33 +14,33 @@ package com.google.cloud.functions.invoker.http; -import static java.util.stream.Collectors.toMap; - import com.google.cloud.functions.HttpRequest; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.UncheckedIOException; -import java.util.AbstractMap.SimpleEntry; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.TreeMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.Part; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPart.Part; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.util.Fields; public class HttpRequestImpl implements HttpRequest { - private final HttpServletRequest request; + private final Request request; + private InputStream inputStream; + private BufferedReader reader; - public HttpRequestImpl(HttpServletRequest request) { + public HttpRequestImpl(Request request) { this.request = request; } @@ -51,133 +51,155 @@ public String getMethod() { @Override public String getUri() { - String url = request.getRequestURL().toString(); - if (request.getQueryString() != null) { - url += "?" + request.getQueryString(); - } - return url; + return request.getHttpURI().asString(); } @Override public String getPath() { - return request.getRequestURI(); + return request.getHttpURI().getCanonicalPath(); } @Override public Optional getQuery() { - return Optional.ofNullable(request.getQueryString()); + return Optional.ofNullable(request.getHttpURI().getQuery()); } @Override public Map> getQueryParameters() { - return request.getParameterMap().entrySet().stream() - .collect(toMap(Map.Entry::getKey, e -> Arrays.asList(e.getValue()))); + Fields fields = Request.extractQueryParameters(request); + if (fields.isEmpty()) { + return Collections.emptyMap(); + } + + Map> map = new HashMap<>(); + fields.forEach( + field -> map.put(field.getName(), Collections.unmodifiableList(field.getValues()))); + return Collections.unmodifiableMap(map); } @Override public Map getParts() { - String contentType = request.getContentType(); - if (contentType == null || !request.getContentType().startsWith("multipart/form-data")) { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + if (contentType == null + || !contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { throw new IllegalStateException("Content-Type must be multipart/form-data: " + contentType); } - try { - return request.getParts().stream().collect(toMap(Part::getName, HttpPartImpl::new)); - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (ServletException e) { - throw new RuntimeException(e.getMessage(), e); + + // The multipart parsing is done by the EagerContentHandler, so we just call getParts. + MultiPartFormData.Parts parts = MultiPartFormData.getParts(request); + if (parts == null) { + throw new IllegalStateException(); + } + + if (parts.size() == 0) { + return Collections.emptyMap(); } + + Map map = new HashMap<>(); + parts.forEach(part -> map.put(part.getName(), new HttpPartImpl(part))); + return Collections.unmodifiableMap(map); } @Override public Optional getContentType() { - return Optional.ofNullable(request.getContentType()); + return Optional.ofNullable(request.getHeaders().get(HttpHeader.CONTENT_TYPE)); } @Override public long getContentLength() { - return request.getContentLength(); + return request.getLength(); } @Override public Optional getCharacterEncoding() { - return Optional.ofNullable(request.getCharacterEncoding()); + Charset charset = Request.getCharset(request); + return Optional.ofNullable(charset == null ? null : charset.name()); } @Override public InputStream getInputStream() throws IOException { - return request.getInputStream(); + if (reader != null) { + throw new IllegalStateException("getReader() already called"); + } + if (inputStream == null) { + inputStream = Content.Source.asInputStream(request); + } + return inputStream; } @Override public BufferedReader getReader() throws IOException { - return request.getReader(); + if (reader == null) { + if (inputStream != null) { + throw new IllegalStateException("getInputStream already called"); + } + inputStream = Content.Source.asInputStream(request); + reader = + new BufferedReader( + new InputStreamReader( + getInputStream(), + Objects.requireNonNullElse(Request.getCharset(request), StandardCharsets.UTF_8))); + } + return reader; } @Override public Map> getHeaders() { - return Collections.list(request.getHeaderNames()).stream() - .collect( - toMap( - name -> name, - name -> Collections.list(request.getHeaders(name)), - (a, b) -> b, - () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); + return HttpUtil.toStringListMap(request.getHeaders()); } private static class HttpPartImpl implements HttpPart { private final Part part; + private final String contentType; private HttpPartImpl(Part part) { this.part = part; + contentType = part.getHeaders().get(HttpHeader.CONTENT_TYPE); } @Override public Optional getFileName() { - return Optional.ofNullable(part.getSubmittedFileName()); + return Optional.ofNullable(part.getFileName()); } @Override public Optional getContentType() { - return Optional.ofNullable(part.getContentType()); + return Optional.ofNullable(contentType); } @Override public long getContentLength() { - return part.getSize(); + return part.getLength(); } @Override public Optional getCharacterEncoding() { - String contentType = getContentType().orElse(null); - if (contentType == null) { - return Optional.empty(); - } - Pattern charsetPattern = Pattern.compile("(?i).*;\\s*charset\\s*=([^;\\s]*)\\s*(;|$)"); - Matcher matcher = charsetPattern.matcher(contentType); - return matcher.matches() ? Optional.of(matcher.group(1)) : Optional.empty(); + return Optional.ofNullable(MimeTypes.getCharsetFromContentType(contentType)); } @Override public InputStream getInputStream() throws IOException { - return part.getInputStream(); + Content.Source contentSource = part.createContentSource(); + return Content.Source.asInputStream(contentSource); } @Override public BufferedReader getReader() throws IOException { - String encoding = getCharacterEncoding().orElse("utf-8"); - return new BufferedReader(new InputStreamReader(getInputStream(), encoding)); + return new BufferedReader( + new InputStreamReader( + getInputStream(), + Objects.requireNonNullElse( + MimeTypes.DEFAULTS.getCharset(contentType), StandardCharsets.UTF_8))); } @Override public Map> getHeaders() { - return part.getHeaderNames().stream() - .map(name -> new SimpleEntry<>(name, list(part.getHeaders(name)))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + return HttpUtil.toStringListMap(part.getHeaders()); } - private static List list(Collection collection) { - return (collection instanceof List) ? (List) collection : new ArrayList<>(collection); + @Override + public String toString() { + return "%s{%s}".formatted(super.toString(), part); } } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java index c02246f0..5773de4b 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java @@ -14,24 +14,33 @@ package com.google.cloud.functions.invoker.http; -import static java.util.stream.Collectors.toMap; - import com.google.cloud.functions.HttpResponse; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Collection; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.TreeMap; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.WriteThroughWriter; +import org.eclipse.jetty.io.content.BufferedContentSink; +import org.eclipse.jetty.io.content.ContentSinkOutputStream; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; public class HttpResponseImpl implements HttpResponse { - private final HttpServletResponse response; + private final Response response; + private ContentSinkOutputStream outputStream; + private BufferedWriter writer; + private Charset charset; - public HttpResponseImpl(HttpServletResponse response) { + public HttpResponseImpl(Response response) { this.response = response; } @@ -43,75 +52,173 @@ public void setStatusCode(int code) { @Override @SuppressWarnings("deprecation") public void setStatusCode(int code, String message) { - response.setStatus(code, message); + response.setStatus(code); } @Override public void setContentType(String contentType) { - response.setContentType(contentType); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, contentType); + charset = response.getRequest().getContext().getMimeTypes().getCharset(contentType); } @Override public Optional getContentType() { - return Optional.ofNullable(response.getContentType()); + return Optional.ofNullable(response.getHeaders().get(HttpHeader.CONTENT_TYPE)); } @Override public void appendHeader(String key, String value) { - response.addHeader(key, value); + if (HttpHeader.CONTENT_TYPE.is(key)) { + setContentType(value); + } else { + response.getHeaders().add(key, value); + } } @Override public Map> getHeaders() { - return response.getHeaderNames().stream() - .collect( - toMap( - name -> name, - name -> new ArrayList<>(response.getHeaders(name)), - (a, b) -> b, - () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); - } - - private static List list(Collection collection) { - return (collection instanceof List) ? (List) collection : new ArrayList<>(collection); + return HttpUtil.toStringListMap(response.getHeaders()); } @Override - public OutputStream getOutputStream() throws IOException { - return response.getOutputStream(); + public OutputStream getOutputStream() { + if (writer != null) { + throw new IllegalStateException("getWriter called"); + } + if (outputStream == null) { + Request request = response.getRequest(); + int outputBufferSize = + request.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize(); + BufferedContentSink bufferedContentSink = + new BufferedContentSink( + response, + request.getComponents().getByteBufferPool(), + false, + outputBufferSize / 2, + outputBufferSize); + + // TODO: remove override of close() when changes from + // https://github.com/jetty/jetty.project/pull/13972 are released. + outputStream = + new ContentSinkOutputStream(bufferedContentSink) { + boolean closed = false; + + @Override + public void close(Callback callback) throws IOException { + if (closed) { + callback.succeeded(); + } + + closed = true; + super.close(callback); + } + }; + } + return outputStream; } - private BufferedWriter writer; - @Override public synchronized BufferedWriter getWriter() throws IOException { if (writer == null) { - // Unfortunately this means that we get two intermediate objects between the object we return - // and the underlying Writer that response.getWriter() wraps. We could try accessing the - // PrintWriter.out field via reflection, but that sort of access to non-public fields of - // platform classes is now frowned on and may draw warnings or even fail in subsequent - // versions. We could instead wrap the OutputStream, but that would require us to deduce the - // appropriate Charset, using logic like this: - // https://github.com/eclipse/jetty.project/blob/923ec38adf/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java#L731 - // We may end up doing that if performance is an issue. - writer = new BufferedWriter(response.getWriter()); + if (outputStream != null) { + throw new IllegalStateException("getOutputStream called"); + } + + writer = + new NonBufferedWriter( + WriteThroughWriter.newWriter( + getOutputStream(), Objects.requireNonNullElse(charset, StandardCharsets.UTF_8))); } return writer; } - public void flush() { + /** + * Close the response, flushing all content. + * + * @param callback a {@link Callback} to be completed when the response is closed. + */ + public void close(Callback callback) { try { - // We can't use HttpServletResponse.flushBuffer() because we wrap the - // PrintWriter returned by HttpServletResponse in our own BufferedWriter - // to match our API. So we have to flush whichever of getWriter() or - // getOutputStream() works. - try { - getOutputStream().flush(); - } catch (IllegalStateException e) { - getWriter().flush(); + // The writer has been constructed to do no buffering, so it does not need to be flushed + if (outputStream != null) { + // Do an asynchronous close, so large buffered content may be written without blocking + outputStream.close(callback); + } else { + callback.succeeded(); } } catch (IOException e) { - // Too bad, can't flush. + // Too bad, can't close. + } + } + + /** + * A {@link BufferedWriter} that does not buffer. It is generally more efficient to buffer at the + * {@link Content.Sink} level, since frequently total content is smaller than a single buffer and + * the {@link Content.Sink} can turn a close into a last write that will avoid chunking the + * response if at all possible. However, {@link BufferedWriter} is in the API for {@link + * HttpResponse}, so we must return a writer of that type. + */ + private static class NonBufferedWriter extends BufferedWriter { + private final Writer writer; + + public NonBufferedWriter(Writer out) { + super(out, 1); + writer = out; + } + + @Override + public void write(int c) throws IOException { + writer.write(c); + } + + @Override + public void write(char[] cbuf) throws IOException { + writer.write(cbuf); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + writer.write(cbuf, off, len); + } + + @Override + public void write(String str) throws IOException { + writer.write(str); + } + + @Override + public void write(String str, int off, int len) throws IOException { + writer.write(str, off, len); + } + + @Override + public Writer append(CharSequence csq) throws IOException { + return writer.append(csq); + } + + @Override + public Writer append(CharSequence csq, int start, int end) throws IOException { + return writer.append(csq, start, end); + } + + @Override + public Writer append(char c) throws IOException { + return writer.append(c); + } + + @Override + public void flush() throws IOException { + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } + + @Override + public void newLine() throws IOException { + writer.write(System.lineSeparator()); } } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java new file mode 100644 index 00000000..042af255 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpUtil.java @@ -0,0 +1,32 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; + +class HttpUtil { + public static Map> toStringListMap(HttpFields headers) { + Map> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (HttpField field : headers) { + map.computeIfAbsent(field.getName(), key -> new ArrayList<>()).add(field.getValue()); + } + return map; + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java new file mode 100644 index 00000000..96295fd2 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/TimeoutHandler.java @@ -0,0 +1,82 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.cloud.functions.invoker.http; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.Scheduler; + +public class TimeoutHandler extends Handler.Wrapper { + private final Duration timeout; + + public TimeoutHandler(int timeoutSeconds, Handler handler) { + setHandler(handler); + timeout = Duration.ofSeconds(timeoutSeconds); + } + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + // Wrap the callback to ensure it is only completed once between the + // handler and the timeout task. + Callback wrappedCallback = new ProtectedCallback(callback); + Scheduler.Task timeoutTask = + request + .getComponents() + .getScheduler() + .schedule( + () -> + wrappedCallback.failed( + new BadMessageException( + HttpStatus.REQUEST_TIMEOUT_408, "Function execution timed out")), + timeout); + + // Cancel the timeout if the request completes the callback first. + return super.handle(request, response, Callback.from(timeoutTask::cancel, wrappedCallback)); + } + + private static class ProtectedCallback implements Callback { + private final Callback callback; + private final AtomicBoolean completed = new AtomicBoolean(false); + + public ProtectedCallback(Callback callback) { + this.callback = callback; + } + + @Override + public void succeeded() { + if (completed.compareAndSet(false, true)) { + callback.succeeded(); + } + } + + @Override + public void failed(Throwable x) { + if (completed.compareAndSet(false, true)) { + callback.failed(x); + } + } + + @Override + public InvocationType getInvocationType() { + return callback.getInvocationType(); + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java index 892d6038..20c3f248 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java @@ -25,6 +25,7 @@ import com.google.cloud.functions.invoker.HttpFunctionExecutor; import com.google.cloud.functions.invoker.TypedFunctionExecutor; import com.google.cloud.functions.invoker.gcf.JsonLogHandler; +import com.google.cloud.functions.invoker.http.TimeoutHandler; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; @@ -44,23 +45,19 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Stream; -import javax.servlet.MultipartConfigElement; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import org.eclipse.jetty.http.HttpStatus; -import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.server.handler.EagerContentHandler; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.thread.QueuedThreadPool; /** @@ -87,7 +84,7 @@ public class Invoker { // if we arrange for them to be formatted using StackDriver's "structured // logging" JSON format. Remove the JDK's standard logger and replace it with // the JSON one. - for (Handler handler : rootLogger.getHandlers()) { + for (java.util.logging.Handler handler : rootLogger.getHandlers()) { rootLogger.removeHandler(handler); } rootLogger.addHandler(new JsonLogHandler(System.out, false)); @@ -238,7 +235,7 @@ ClassLoader getFunctionClassLoader() { * unit or integration test, use {@link #startTestServer()} instead. * * @see #stopServer() - * @throws Exception + * @throws Exception If there was a problem starting the server */ public void startServer() throws Exception { startServer(true); @@ -270,7 +267,7 @@ public void startServer() throws Exception { * } * * @see #stopServer() - * @throws Exception + * @throws Exception If there was a problem starting the server */ public void startTestServer() throws Exception { startServer(false); @@ -283,34 +280,46 @@ private void startServer(boolean join) throws Exception { QueuedThreadPool pool = new QueuedThreadPool(1024); server = new Server(pool); + server.setErrorHandler( + new ErrorHandler() { + @Override + protected void generateResponse( + Request request, + Response response, + int code, + String message, + Throwable cause, + Callback callback) { + // Suppress error body + callback.succeeded(); + } + }); ServerConnector connector = new ServerConnector(server); connector.setPort(port); - server.setConnectors(new Connector[] {connector}); - - ServletContextHandler servletContextHandler = new ServletContextHandler(); - servletContextHandler.setContextPath("/"); - server.setHandler(NotFoundHandler.forServlet(servletContextHandler)); + connector.setReuseAddress(true); + connector.setReusePort(true); + server.addConnector(connector); Class functionClass = loadFunctionClass(); - HttpServlet servlet; + Handler handler; if (functionSignatureType == null) { - servlet = servletForDeducedSignatureType(functionClass); + handler = handlerForDeducedSignatureType(functionClass); } else { switch (functionSignatureType) { case "http": if (TypedFunction.class.isAssignableFrom(functionClass)) { - servlet = TypedFunctionExecutor.forClass(functionClass); + handler = TypedFunctionExecutor.forClass(functionClass); } else { - servlet = HttpFunctionExecutor.forClass(functionClass); + handler = HttpFunctionExecutor.forClass(functionClass); } break; case "event": case "cloudevent": - servlet = BackgroundFunctionExecutor.forClass(functionClass); + handler = BackgroundFunctionExecutor.forClass(functionClass); break; case "typed": - servlet = TypedFunctionExecutor.forClass(functionClass); + handler = TypedFunctionExecutor.forClass(functionClass); break; default: String error = @@ -321,9 +330,18 @@ private void startServer(boolean join) throws Exception { throw new RuntimeException(error); } } - ServletHolder servletHolder = new ServletHolder(servlet); - servletHolder.getRegistration().setMultipartConfig(new MultipartConfigElement("")); - servletContextHandler.addServlet(servletHolder, "/*"); + + // Possibly wrap with TimeoutHandler if CLOUD_RUN_TIMEOUT_SECONDS is set. + handler = addTimerHandlerForRequestTimeout(handler); + server.setHandler(handler); + + // Add a handler to asynchronously parse multipart before invoking the function. + MultiPartConfig config = new MultiPartConfig.Builder().maxMemoryPartSize(-1).build(); + EagerContentHandler.MultiPartContentLoaderFactory factory = + new EagerContentHandler.MultiPartContentLoaderFactory(config); + server.insertHandler(new EagerContentHandler(factory)); + + server.insertHandler(new NotFoundHandler()); server.start(); logServerInfo(); @@ -371,7 +389,7 @@ private Class loadFunctionClass() throws ClassNotFoundException { } } - private HttpServlet servletForDeducedSignatureType(Class functionClass) { + private Handler handlerForDeducedSignatureType(Class functionClass) { if (HttpFunction.class.isAssignableFrom(functionClass)) { return HttpFunctionExecutor.forClass(functionClass); } @@ -393,6 +411,15 @@ private HttpServlet servletForDeducedSignatureType(Class functionClass) { throw new RuntimeException(error); } + private Handler addTimerHandlerForRequestTimeout(Handler handler) { + String timeoutSeconds = System.getenv("CLOUD_RUN_TIMEOUT_SECONDS"); + if (timeoutSeconds == null) { + return handler; + } + int seconds = Integer.parseInt(timeoutSeconds); + return new TimeoutHandler(seconds, handler); + } + static URL[] classpathToUrls(String classpath) { String[] components = classpath.split(File.pathSeparator); List urls = new ArrayList<>(); @@ -451,32 +478,24 @@ private static boolean isGcf() { /** * Wrapper that intercepts requests for {@code /favicon.ico} and {@code /robots.txt} and causes - * them to produce a 404 status. Otherwise they would be sent to the function code, like any other - * URL, meaning that someone testing their function by using a browser as an HTTP client can see - * two requests, one for {@code /favicon.ico} and one for {@code /} (or whatever). + * them to produce a 404 status. Otherwise, they would be sent to the function code, like any + * other URL, meaning that someone testing their function by using a browser as an HTTP client can + * see two requests, one for {@code /favicon.ico} and one for {@code /} (or whatever). */ - private static class NotFoundHandler extends HandlerWrapper { - static NotFoundHandler forServlet(ServletContextHandler servletHandler) { - NotFoundHandler handler = new NotFoundHandler(); - handler.setHandler(servletHandler); - return handler; - } + private static class NotFoundHandler extends Handler.Wrapper { private static final Set NOT_FOUND_PATHS = new HashSet<>(Arrays.asList("/favicon.ico", "/robots.txt")); @Override - public void handle( - String target, - Request baseRequest, - HttpServletRequest request, - HttpServletResponse response) - throws IOException, ServletException { - if (NOT_FOUND_PATHS.contains(request.getRequestURI())) { - response.sendError(HttpStatus.NOT_FOUND_404, "Not Found"); - return; + public boolean handle(Request request, Response response, Callback callback) throws Exception { + if (NOT_FOUND_PATHS.contains(request.getHttpURI().getCanonicalPath())) { + response.setStatus(HttpStatus.NOT_FOUND_404); + callback.succeeded(); + return true; } - super.handle(target, baseRequest, request, response); + + return super.handle(request, response, callback); } } @@ -505,7 +524,6 @@ private static class OnlyApiClassLoader extends ClassLoader { protected Class findClass(String name) throws ClassNotFoundException { String prefix = "com.google.cloud.functions."; if ((name.startsWith(prefix) && Character.isUpperCase(name.charAt(prefix.length()))) - || name.startsWith("javax.servlet.") || isCloudEventsApiClass(name)) { return runtimeClassLoader.loadClass(name); } diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java index 2b7211c9..87b9bd31 100644 --- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java @@ -2,7 +2,6 @@ import static com.google.cloud.functions.invoker.BackgroundFunctionExecutor.backgroundFunctionTypeArgument; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth8.assertThat; import com.google.cloud.functions.BackgroundFunction; import com.google.cloud.functions.Context; diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java index 3f3de837..d6e3b14a 100644 --- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java @@ -28,6 +28,8 @@ import com.google.common.truth.Expect; import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; @@ -39,11 +41,13 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.StringReader; import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.URI; import java.net.URL; import java.net.URLEncoder; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -51,6 +55,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -62,16 +67,17 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +import org.eclipse.jetty.client.ByteBufferRequestContent; +import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentProvider; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.client.util.MultiPartContentProvider; -import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPart.ContentSourcePart; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -87,7 +93,9 @@ public class IntegrationTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); @Rule public final TestName testName = new TestName(); - private static final String SERVER_READY_STRING = "Started ServerConnector"; + private static final String SERVER_READY_STRING = "Started oejs.ServerConnector"; + private static final String EXECUTION_ID_HTTP_HEADER = "HTTP_FUNCTION_EXECUTION_ID"; + private static final String EXECUTION_ID = "1234abcd"; private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(); @@ -161,10 +169,18 @@ abstract static class TestCase { abstract String url(); - abstract ContentProvider requestContent(); + abstract Request.Content requestContent(); abstract int expectedResponseCode(); + /** + * Expected response headers map, header name -> value. Value "*" asserts the header is present + * with any value. Value "-" asserts the header is not present. + * + * @return the expected response headers for this test case. + */ + abstract Optional> expectedResponseHeaders(); + abstract Optional expectedResponseText(); abstract Optional expectedJson(); @@ -184,6 +200,7 @@ static Builder builder() { .setUrl("/") .setRequestText("") .setExpectedResponseCode(HttpStatus.OK_200) + .setExpectedResponseHeaders(ImmutableMap.of()) .setExpectedResponseText("") .setHttpContentType("text/plain") .setHttpHeaders(ImmutableMap.of()); @@ -194,14 +211,16 @@ abstract static class Builder { abstract Builder setUrl(String x); - abstract Builder setRequestContent(ContentProvider x); + abstract Builder setRequestContent(Request.Content x); Builder setRequestText(String text) { - return setRequestContent(new StringContentProvider(text)); + return setRequestContent(new StringRequestContent(text)); } abstract Builder setExpectedResponseCode(int x); + abstract Builder setExpectedResponseHeaders(Map x); + abstract Builder setExpectedResponseText(String x); abstract Builder setExpectedResponseText(Optional x); @@ -247,17 +266,81 @@ public void helloWorld() throws Exception { testHttpFunction( fullTarget("HelloWorld"), ImmutableList.of( - TestCase.builder().setExpectedResponseText("hello\n").build(), + TestCase.builder() + .setExpectedResponseHeaders(ImmutableMap.of("Content-Length", "*")) + .setExpectedResponseText("hello\n") + .build(), FAVICON_TEST_CASE, ROBOTS_TXT_TEST_CASE)); } + @Test + public void timeoutHttpSuccess() throws Exception { + testFunction( + SignatureType.HTTP, + fullTarget("TimeoutHttp"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseText("finished\n") + .setExpectedResponseText(Optional.empty()) + .build()), + ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "3")); + } + + @Test + public void timeoutHttpTimesOut() throws Exception { + testFunction( + SignatureType.HTTP, + fullTarget("TimeoutHttp"), + ImmutableList.of(), + ImmutableList.of( + TestCase.builder() + .setExpectedResponseCode(408) + .setExpectedResponseText(Optional.empty()) + .build()), + ImmutableMap.of("CLOUD_RUN_TIMEOUT_SECONDS", "1")); + } + + @Test + public void bufferedWrites() throws Exception { + // This test checks that writes are buffered, and are written + // in an efficient way with known content-length if possible + // instead of doing a chunked response. + testHttpFunction( + fullTarget("BufferedWrites"), + ImmutableList.of( + TestCase.builder() + .setUrl("/target?writes=2") + .setExpectedResponseText("write 0\nwrite 1\n") + .setExpectedResponseHeaders( + ImmutableMap.of( + "x-write-0", "true", + "x-write-1", "true", + "x-written", "true", + "Content-Length", "16")) + .build(), + TestCase.builder() + .setUrl("/target?writes=2&flush=true") + .setExpectedResponseText("write 0\nwrite 1\n") + .setExpectedResponseHeaders( + ImmutableMap.of( + "x-write-0", "true", + "x-write-1", "true", + "x-written", "-", + "Transfer-Encoding", "chunked")) + .build())); + } + @Test public void exceptionHttp() throws Exception { String exceptionExpectedOutput = "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + " \"com/google/cloud/functions/invoker/HttpFunctionExecutor.java\", \"method\":" - + " \"service\"}, \"message\": \"Failed to execute" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\"," + + " \"message\": \"Failed to execute" + " com.google.cloud.functions.invoker.testfunctions.ExceptionHttp\\n" + "java.lang.RuntimeException: exception thrown for test"; testHttpFunction( @@ -265,6 +348,8 @@ public void exceptionHttp() throws Exception { ImmutableList.of( TestCase.builder() .setExpectedResponseCode(500) + .setExpectedResponseText("") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedOutput(exceptionExpectedOutput) .build())); } @@ -274,7 +359,10 @@ public void exceptionBackground() throws Exception { String exceptionExpectedOutput = "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":" - + " \"service\"}, \"message\": \"Failed to execute" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"Failed to execute" + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n" + "java.lang.RuntimeException: exception thrown for test"; @@ -288,9 +376,11 @@ public void exceptionBackground() throws Exception { ImmutableList.of( TestCase.builder() .setRequestText(gcfRequestText) + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedResponseCode(500) .setExpectedOutput(exceptionExpectedOutput) - .build())); + .build()), + Collections.emptyMap()); } @Test @@ -329,13 +419,21 @@ public void stackDriverLogging() throws Exception { + "\"logging.googleapis.com/sourceLocation\": " + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\"," + " \"method\": \"service\"}," + + " \"execution_id\": \"" + + EXECUTION_ID + + "\"," + " \"message\": \"blim\"}"; TestCase simpleTestCase = - TestCase.builder().setUrl("/?message=blim").setExpectedOutput(simpleExpectedOutput).build(); + TestCase.builder() + .setUrl("/?message=blim") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedOutput(simpleExpectedOutput) + .build(); String quotingExpectedOutput = "\"message\": \"foo\\nbar\\\""; TestCase quotingTestCase = TestCase.builder() .setUrl("/?message=" + URLEncoder.encode("foo\nbar\"", "UTF-8")) + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedOutput(quotingExpectedOutput) .build(); String exceptionExpectedOutput = @@ -343,11 +441,15 @@ public void stackDriverLogging() throws Exception { + "\"logging.googleapis.com/sourceLocation\": " + "{\"file\": \"com/google/cloud/functions/invoker/testfunctions/Log.java\", " + "\"method\": \"service\"}, " + + "\"execution_id\": \"" + + EXECUTION_ID + + "\", " + "\"message\": \"oops\\njava.lang.Exception: disaster\\n" + " at com.google.cloud.functions.invoker.testfunctions.Log.service(Log.java:"; TestCase exceptionTestCase = TestCase.builder() .setUrl("/?message=oops&level=severe&exception=disaster") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) .setExpectedOutput(exceptionExpectedOutput) .build(); testHttpFunction( @@ -400,7 +502,8 @@ public void typedFunction() throws Exception { TestCase.builder() .setRequestText(originalJson) .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}") - .build())); + .build()), + Collections.emptyMap()); } @Test @@ -410,7 +513,8 @@ public void typedVoidFunction() throws Exception { fullTarget("TypedVoid"), ImmutableList.of(), ImmutableList.of( - TestCase.builder().setRequestText("{}").setExpectedResponseCode(204).build())); + TestCase.builder().setRequestText("{}").setExpectedResponseCode(204).build()), + Collections.emptyMap()); } @Test @@ -424,7 +528,8 @@ public void typedCustomFormat() throws Exception { .setRequestText("abc\n123\n$#@\n") .setExpectedResponseText("abc123$#@") .setExpectedResponseCode(200) - .build())); + .build()), + Collections.emptyMap()); } private void backgroundTest(String target) throws Exception { @@ -519,6 +624,43 @@ public void nativeCloudEvent() throws Exception { ImmutableList.of(cloudEventsStructuredTestCase, cloudEventsBinaryTestCase)); } + /** Tests a CloudEvent being handled by a CloudEvent handler throws exception */ + @Test + public void nativeCloudEventException() throws Exception { + String exceptionExpectedOutput = + "\"severity\": \"ERROR\", \"logging.googleapis.com/sourceLocation\": {\"file\":" + + " \"com/google/cloud/functions/invoker/BackgroundFunctionExecutor.java\", \"method\":" + + " \"handle\"}, \"execution_id\": \"" + + EXECUTION_ID + + "\", " + + "\"message\": \"Failed to execute" + + " com.google.cloud.functions.invoker.testfunctions.ExceptionBackground\\n" + + "java.lang.RuntimeException: exception thrown for test"; + File snoopFile = snoopFile(); + CloudEvent cloudEvent = sampleCloudEvent(snoopFile); + EventFormat jsonFormat = + EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); + String cloudEventJson = new String(jsonFormat.serialize(cloudEvent), UTF_8); + + // A CloudEvent using the "structured content mode", where both the metadata and the payload + // are in the body of the HTTP request. + TestCase cloudEventsStructuredTestCase = + TestCase.builder() + .setRequestText(cloudEventJson) + .setHttpContentType("application/cloudevents+json; charset=utf-8") + .setHttpHeaders(ImmutableMap.of(EXECUTION_ID_HTTP_HEADER, EXECUTION_ID)) + .setExpectedResponseCode(500) + .setExpectedOutput(exceptionExpectedOutput) + .build(); + + testFunction( + SignatureType.CLOUD_EVENT, + fullTarget("ExceptionBackground"), + ImmutableList.of(), + ImmutableList.of(cloudEventsStructuredTestCase), + Collections.emptyMap()); + } + @Test public void nested() throws Exception { String testText = "sic transit gloria mundi"; @@ -537,11 +679,16 @@ public void packageless() throws Exception { @Test public void multipart() throws Exception { - MultiPartContentProvider multiPartProvider = new MultiPartContentProvider(); + MultiPartRequestContent multiPartRequestContent = new MultiPartRequestContent(); byte[] bytes = new byte[17]; - multiPartProvider.addFieldPart("bytes", new BytesContentProvider(bytes), new HttpFields()); - String string = "1234567890"; - multiPartProvider.addFieldPart("string", new StringContentProvider(string), new HttpFields()); + multiPartRequestContent.addPart( + new ContentSourcePart( + "bytes", null, HttpFields.EMPTY, new ByteBufferRequestContent(ByteBuffer.wrap(bytes)))); + multiPartRequestContent.addPart( + new MultiPart.ContentSourcePart( + "string", null, HttpFields.EMPTY, new StringRequestContent("1234567890"))); + multiPartRequestContent.close(); + String expectedResponse = "part bytes type application/octet-stream length 17\n" + "part string type text/plain;charset=UTF-8 length 10\n"; @@ -549,8 +696,8 @@ public void multipart() throws Exception { fullTarget("Multipart"), ImmutableList.of( TestCase.builder() - .setHttpContentType(Optional.empty()) - .setRequestContent(multiPartProvider) + .setHttpContentType(multiPartRequestContent.getContentType()) + .setRequestContent(multiPartRequestContent) .setExpectedResponseText(expectedResponse) .build())); } @@ -595,7 +742,8 @@ public void classpathOptionHttp() throws Exception { SignatureType.HTTP, "com.example.functionjar.Foreground", ImmutableList.of("--classpath", functionJarString()), - ImmutableList.of(testCase)); + ImmutableList.of(testCase), + Collections.emptyMap()); } /** Like {@link #classpathOptionHttp} but for background functions. */ @@ -612,7 +760,8 @@ public void classpathOptionBackground() throws Exception { SignatureType.BACKGROUND, "com.example.functionjar.Background", ImmutableList.of("--classpath", functionJarString()), - ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build())); + ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build()), + Collections.emptyMap()); } /** Like {@link #classpathOptionHttp} but for typed functions. */ @@ -629,7 +778,8 @@ public void classpathOptionTyped() throws Exception { TestCase.builder() .setRequestText(originalJson) .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}") - .build())); + .build()), + Collections.emptyMap()); } // In these tests, we test a number of different functions that express the same functionality @@ -643,7 +793,12 @@ private void backgroundTest( for (TestCase testCase : testCases) { File snoopFile = testCase.snoopFile().get(); snoopFile.delete(); - testFunction(signatureType, functionTarget, ImmutableList.of(), ImmutableList.of(testCase)); + testFunction( + signatureType, + functionTarget, + ImmutableList.of(), + ImmutableList.of(testCase), + Collections.emptyMap()); String snooped = new String(Files.readAllBytes(snoopFile.toPath()), StandardCharsets.UTF_8); Gson gson = new Gson(); JsonObject snoopedJson = gson.fromJson(snooped, JsonObject.class); @@ -667,33 +822,59 @@ private void checkSnoopFile(TestCase testCase) throws IOException { } private void testHttpFunction(String target, List testCases) throws Exception { - testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases); + testFunction(SignatureType.HTTP, target, ImmutableList.of(), testCases, Collections.emptyMap()); } private void testFunction( SignatureType signatureType, String target, ImmutableList extraArgs, - List testCases) + List testCases, + Map environmentVariables) throws Exception { - ServerProcess serverProcess = startServer(signatureType, target, extraArgs); + ServerProcess serverProcess = + startServer(signatureType, target, extraArgs, environmentVariables); + HttpClient httpClient = new HttpClient(); try { - HttpClient httpClient = new HttpClient(); httpClient.start(); for (TestCase testCase : testCases) { testCase.snoopFile().ifPresent(File::delete); String uri = "http://localhost:" + serverPort + testCase.url(); Request request = httpClient.POST(uri); - testCase - .httpContentType() - .ifPresent(contentType -> request.header(HttpHeader.CONTENT_TYPE, contentType)); - testCase.httpHeaders().forEach((header, value) -> request.header(header, value)); - request.content(testCase.requestContent()); + + request.headers( + headers -> { + testCase + .httpContentType() + .ifPresent(contentType -> headers.put(HttpHeader.CONTENT_TYPE, contentType)); + testCase.httpHeaders().forEach(headers::put); + }); + request.body(testCase.requestContent()); ContentResponse response = request.send(); expect .withMessage("Response to %s is %s %s", uri, response.getStatus(), response.getReason()) .that(response.getStatus()) .isEqualTo(testCase.expectedResponseCode()); + testCase + .expectedResponseHeaders() + .ifPresent( + expectedResponseHeaders -> { + for (Map.Entry entry : expectedResponseHeaders.entrySet()) { + if ("*".equals(entry.getValue())) { + expect + .that(response.getHeaders().getFieldNamesCollection()) + .contains(entry.getKey()); + } else if ("-".equals(entry.getValue())) { + expect + .that(response.getHeaders().getFieldNamesCollection()) + .doesNotContain(entry.getKey()); + } else { + expect + .that(response.getHeaders().getValuesList(entry.getKey())) + .contains(entry.getValue()); + } + } + }); testCase .expectedResponseText() .ifPresent(text -> expect.that(response.getContentAsString()).isEqualTo(text)); @@ -706,11 +887,16 @@ private void testFunction( } } finally { serverProcess.close(); + httpClient.stop(); } for (TestCase testCase : testCases) { testCase .expectedOutput() - .ifPresent(output -> expect.that(serverProcess.output()).contains(output)); + .ifPresent( + (output) -> { + expect.that(serverProcess.output()).contains(output); + parseLogJson(serverProcess.output()); + }); } // Wait for the output monitor task to terminate. If it threw an exception, we will get an // ExecutionException here. @@ -772,7 +958,10 @@ public void close() { } private ServerProcess startServer( - SignatureType signatureType, String target, ImmutableList extraArgs) + SignatureType signatureType, + String target, + ImmutableList extraArgs, + Map environmentVariables) throws IOException, InterruptedException { File javaHome = new File(System.getProperty("java.home")); assertThat(javaHome.exists()).isTrue(); @@ -796,8 +985,11 @@ private ServerProcess startServer( "FUNCTION_SIGNATURE_TYPE", signatureType.toString(), "FUNCTION_TARGET", - target); + target, + "LOG_EXECUTION_ID", + "true"); processBuilder.environment().putAll(environment); + processBuilder.environment().putAll(environmentVariables); Process serverProcess = processBuilder.start(); CountDownLatch ready = new CountDownLatch(1); StringBuilder output = new StringBuilder(); @@ -832,4 +1024,12 @@ private void monitorOutput( throw new UncheckedIOException(e); } } + + // Attempt to parse Json object, throws on parse failure + private void parseLogJson(String json) throws RuntimeException { + System.out.println("trying to parse the following object "); + System.out.println(json); + JsonReader reader = new JsonReader(new StringReader(json)); + JsonParser.parseReader(reader); + } } diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java index 969d1dcc..668d60c8 100644 --- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java @@ -1,7 +1,6 @@ package com.google.cloud.functions.invoker; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth8.assertThat; import com.google.cloud.functions.TypedFunction; import org.junit.Test; diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java index e52ec62a..a9794cd2 100644 --- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/http/HttpTest.java @@ -15,7 +15,6 @@ package com.google.cloud.functions.invoker.http; import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth8.assertThat; import static org.junit.Assert.fail; import com.google.cloud.functions.HttpRequest; @@ -28,6 +27,7 @@ import java.io.InputStreamReader; import java.io.OutputStream; import java.net.ServerSocket; +import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -35,22 +35,23 @@ import java.util.TreeMap; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import javax.servlet.MultipartConfigElement; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.client.ByteBufferRequestContent; +import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.client.util.MultiPartContentProvider; -import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.client.MultiPartRequestContent; +import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpStatus.Code; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartConfig; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.server.handler.EagerContentHandler; +import org.eclipse.jetty.util.Callback; import org.junit.BeforeClass; import org.junit.Test; @@ -82,21 +83,22 @@ public static void allocateServerPort() throws IOException { } /** - * Wrapper class that allows us to start a Jetty server with a single servlet for {@code /*} - * within a try-with-resources statement. The servlet will be configured to support multipart + * Wrapper class that allows us to start a Jetty server with a single handler for {@code /*} + * within a try-with-resources statement. The handler will be configured to support multipart * requests. */ private static class SimpleServer implements AutoCloseable { private final Server server; - SimpleServer(HttpServlet servlet) throws Exception { + SimpleServer(Handler handler) throws Exception { this.server = new Server(serverPort); - ServletContextHandler context = new ServletContextHandler(); - context.setContextPath("/"); - server.setHandler(context); - ServletHolder servletHolder = new ServletHolder(servlet); - servletHolder.getRegistration().setMultipartConfig(new MultipartConfigElement("tiddly")); - context.addServlet(servletHolder, "/*"); + server.setHandler(handler); + + MultiPartConfig config = new MultiPartConfig.Builder().maxMemoryPartSize(-1).build(); + EagerContentHandler.MultiPartContentLoaderFactory factory = + new EagerContentHandler.MultiPartContentLoaderFactory(config); + server.insertHandler(new EagerContentHandler(factory)); + server.start(); } @@ -113,16 +115,16 @@ private interface HttpRequestTest { /** * Tests methods on the {@link HttpRequest} object while the request is being serviced. We are not - * guaranteed that the underlying {@link HttpServletRequest} object will still be valid when the - * request completes, and in fact in Jetty it isn't. So we perform the checks in the context of - * the servlet, and report any exception back to the test method. + * guaranteed that the underlying {@link Request} object will still be valid when the request + * completes, and in fact in Jetty it isn't. So we perform the checks in the context of the + * handler, and report any exception back to the test method. */ @Test public void httpRequestMethods() throws Exception { AtomicReference testReference = new AtomicReference<>(); AtomicReference exceptionReference = new AtomicReference<>(); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpRequestMethods(testReference, exceptionReference); } } @@ -193,14 +195,17 @@ private void httpRequestMethods( }; for (HttpRequestTest test : tests) { testReference.set(test); - Request request = + org.eclipse.jetty.client.Request request = httpClient .POST(uri) - .header(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8") - .header("foo", "bar") - .header("foo", "baz") - .header("CaSe-SeNsItIvE", "VaLuE") - .content(new StringContentProvider(TEST_BODY)); + .headers( + m -> { + m.add(HttpHeader.CONTENT_TYPE, "text/plain; charset=utf-8"); + m.add("foo", "bar"); + m.add("foo", "baz"); + m.add("CaSe-SeNsItIvE", "VaLuE"); + }) + .body(new StringRequestContent(TEST_BODY)); ContentResponse response = request.send(); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); throwIfNotNull(exceptionReference.get()); @@ -223,8 +228,8 @@ public void emptyRequest() throws Exception { }; AtomicReference exceptionReference = new AtomicReference<>(); AtomicReference testReference = new AtomicReference<>(test); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { ContentResponse response = httpClient.POST(uri).send(); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); throwIfNotNull(exceptionReference.get()); @@ -240,20 +245,24 @@ private void validateReader(BufferedReader reader) { public void multiPartRequest() throws Exception { AtomicReference testReference = new AtomicReference<>(); AtomicReference exceptionReference = new AtomicReference<>(); - HttpRequestServlet testServlet = new HttpRequestServlet(testReference, exceptionReference); + HttpRequestHandler testHandler = new HttpRequestHandler(testReference, exceptionReference); HttpClient httpClient = new HttpClient(); httpClient.start(); String uri = "http://localhost:" + serverPort + "/"; - MultiPartContentProvider multiPart = new MultiPartContentProvider(); - HttpFields textHttpFields = new HttpFields(); - textHttpFields.add("foo", "bar"); - multiPart.addFieldPart("text", new StringContentProvider(TEST_BODY), textHttpFields); - HttpFields bytesHttpFields = new HttpFields(); - bytesHttpFields.add("foo", "baz"); - bytesHttpFields.add("foo", "buh"); + MultiPartRequestContent multiPart = new MultiPartRequestContent(); + HttpFields textHttpFields = HttpFields.build().add("foo", "bar"); + multiPart.addPart( + new MultiPart.ContentSourcePart( + "text", null, textHttpFields, new StringRequestContent(TEST_BODY))); + HttpFields.Mutable bytesHttpFields = HttpFields.build().add("foo", "baz").add("foo", "buh"); assertThat(bytesHttpFields.getValuesList("foo")).containsExactly("baz", "buh"); - multiPart.addFilePart( - "binary", "/tmp/binary.x", new BytesContentProvider(RANDOM_BYTES), bytesHttpFields); + multiPart.addPart( + new MultiPart.ContentSourcePart( + "binary", + "/tmp/binary.x", + bytesHttpFields, + new ByteBufferRequestContent(ByteBuffer.wrap(RANDOM_BYTES)))); + multiPart.close(); HttpRequestTest test = request -> { // The Content-Type header will also have a boundary=something attribute. @@ -272,10 +281,9 @@ public void multiPartRequest() throws Exception { assertThat(bytesPart.getFileName()).hasValue("/tmp/binary.x"); assertThat(bytesPart.getContentLength()).isEqualTo(RANDOM_BYTES.length); assertThat(bytesPart.getContentType()).hasValue("application/octet-stream"); - // We only see ["buh"] here, not ["baz", "buh"], apparently due to a Jetty bug. - // Repeated headers on multi-part content are not a big problem anyway. List foos = bytesPart.getHeaders().get("foo"); - assertThat(foos).contains("buh"); + assertThat(foos).containsExactly("baz", "buh"); + byte[] bytes = new byte[RANDOM_BYTES.length]; try (InputStream inputStream = bytesPart.getInputStream()) { assertThat(inputStream.read(bytes)).isEqualTo(bytes.length); @@ -283,20 +291,21 @@ public void multiPartRequest() throws Exception { assertThat(bytes).isEqualTo(RANDOM_BYTES); } }; - try (SimpleServer server = new SimpleServer(testServlet)) { + try (SimpleServer server = new SimpleServer(testHandler)) { testReference.set(test); - Request request = httpClient.POST(uri).header("foo", "oof").content(multiPart); + org.eclipse.jetty.client.Request request = + httpClient.POST(uri).headers(m -> m.put("foo", "oof")).body(multiPart); ContentResponse response = request.send(); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); throwIfNotNull(exceptionReference.get()); } } - private static class HttpRequestServlet extends HttpServlet { + private static class HttpRequestHandler extends Handler.Abstract { private final AtomicReference testReference; private final AtomicReference exceptionReference; - private HttpRequestServlet( + private HttpRequestHandler( AtomicReference testReference, AtomicReference exceptionReference) { this.testReference = testReference; @@ -304,12 +313,15 @@ private HttpRequestServlet( } @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + public boolean handle(Request request, Response response, Callback callback) { try { - testReference.get().test(new HttpRequestImpl(req)); + testReference.get().test(new HttpRequestImpl(request)); } catch (Throwable t) { exceptionReference.set(t); + Response.writeError(request, response, callback, t); } + callback.succeeded(); + return true; } } @@ -327,8 +339,8 @@ private interface HttpResponseTest { public void httpResponseSetAndGet() throws Exception { AtomicReference testReference = new AtomicReference<>(); AtomicReference exceptionReference = new AtomicReference<>(); - HttpResponseServlet testServlet = new HttpResponseServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpResponseSetAndGet(testReference, exceptionReference); } } @@ -350,8 +362,7 @@ private void httpResponseSetAndGet( .containsAtLeast("Content-Type", Arrays.asList("application/octet-stream")); }, response -> { - Map> initialHeaders = response.getHeaders(); - // The servlet spec says this should be empty, but actually we get a Date header here. + // The fields are initialized with a Date header as per the HTTP RFCs. // So we just check that we can add our own headers. response.appendHeader("foo", "bar"); response.appendHeader("wibbly", "wobbly"); @@ -366,18 +377,18 @@ private void httpResponseSetAndGet( HttpClient httpClient = new HttpClient(); httpClient.start(); String uri = "http://localhost:" + serverPort; - Request request = httpClient.POST(uri); + org.eclipse.jetty.client.Request request = httpClient.POST(uri); ContentResponse response = request.send(); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK_200); throwIfNotNull(exceptionReference.get()); } } - private static class HttpResponseServlet extends HttpServlet { + private static class HttpResponseHandler extends Handler.Abstract { private final AtomicReference testReference; private final AtomicReference exceptionReference; - private HttpResponseServlet( + private HttpResponseHandler( AtomicReference testReference, AtomicReference exceptionReference) { this.testReference = testReference; @@ -385,12 +396,15 @@ private HttpResponseServlet( } @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + public boolean handle(Request request, Response response, Callback callback) { try { - testReference.get().test(new HttpResponseImpl(resp)); + testReference.get().test(new HttpResponseImpl(response)); + callback.succeeded(); } catch (Throwable t) { exceptionReference.set(t); + Response.writeError(request, response, callback, t); } + return true; } } @@ -417,15 +431,15 @@ private static ResponseTest responseTest( /** * Tests that operations on the {@link HttpResponse} have the appropriate effect on the HTTP * response that ends up being sent. Here, for each check, we have two operations: the operation - * on the {@link HttpResponse}, which happens inside the servlet, and the operation to check the + * on the {@link HttpResponse}, which happens inside the handler, and the operation to check the * HTTP result, which happens in the client thread. */ @Test public void httpResponseEffects() throws Exception { AtomicReference testReference = new AtomicReference<>(); AtomicReference exceptionReference = new AtomicReference<>(); - HttpResponseServlet testServlet = new HttpResponseServlet(testReference, exceptionReference); - try (SimpleServer server = new SimpleServer(testServlet)) { + HttpResponseHandler testHandler = new HttpResponseHandler(testReference, exceptionReference); + try (SimpleServer server = new SimpleServer(testHandler)) { httpResponseEffects(testReference, exceptionReference); } } @@ -448,7 +462,8 @@ private void httpResponseEffects( response -> response.setStatusCode(HttpStatus.IM_A_TEAPOT_418, "Je suis une théière"), response -> { assertThat(response.getStatus()).isEqualTo(HttpStatus.IM_A_TEAPOT_418); - assertThat(response.getReason()).isEqualTo("Je suis une théière"); + // Reason string cannot be set by the application. + assertThat(response.getReason()).isEqualTo(Code.IM_A_TEAPOT.getMessage()); }), responseTest( response -> response.setContentType("application/noddy"), @@ -491,7 +506,7 @@ private void httpResponseEffects( HttpClient httpClient = new HttpClient(); httpClient.start(); String uri = "http://localhost:" + serverPort; - Request request = httpClient.POST(uri); + org.eclipse.jetty.client.Request request = httpClient.POST(uri); ContentResponse response = request.send(); throwIfNotNull(exceptionReference.get()); test.responseCheck.test(response); diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java index b3569e4e..c1a7ca29 100644 --- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/runner/InvokerTest.java @@ -2,7 +2,6 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.common.truth.Truth8.assertThat; import static java.util.stream.Collectors.joining; import java.io.ByteArrayOutputStream; diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java new file mode 100644 index 00000000..a7989a74 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/BufferedWrites.java @@ -0,0 +1,27 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedWriter; +import java.util.List; +import java.util.Map; + +public class BufferedWrites implements HttpFunction { + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + Map> queryParameters = request.getQueryParameters(); + int writes = Integer.parseInt(request.getFirstQueryParameter("writes").orElse("0")); + boolean flush = Boolean.parseBoolean(request.getFirstQueryParameter("flush").orElse("false")); + + BufferedWriter writer = response.getWriter(); + for (int i = 0; i < writes; i++) { + response.appendHeader("x-write-" + i, "true"); + writer.write("write " + i + "\n"); + } + if (flush) { + writer.flush(); + } + response.appendHeader("x-written", "true"); + } +} diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java new file mode 100644 index 00000000..c73e52d2 --- /dev/null +++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TimeoutHttp.java @@ -0,0 +1,18 @@ +package com.google.cloud.functions.invoker.testfunctions; + +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; + +public class TimeoutHttp implements HttpFunction { + + @Override + public void service(HttpRequest request, HttpResponse response) throws Exception { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + response.getWriter().close(); + } + response.getWriter().write("finished\n"); + } +} diff --git a/invoker/pom.xml b/invoker/pom.xml index b9b94e60..33cf5383 100644 --- a/invoker/pom.xml +++ b/invoker/pom.xml @@ -8,14 +8,14 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT pom GCF Java Invoker Parent Parent POM for the GCF Java Invoker. The project is structured like this so that we can have modules that build jar files for use in tests. - https://github.com/GoogleCloudPlatform/functions-framework-java/tree/main/invoker + https://github.com/GoogleCloudPlatform/functions-framework-java http://github.com/GoogleCloudPlatform/functions-framework-java @@ -24,6 +24,29 @@ HEAD + + + Andras Kerekes + akerekes@google.com + Google LLC + http://www.google.com + + + Di Xu + dixuswe@google.com + Google LLC + http://www.google.com + + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + core testfunction @@ -33,8 +56,8 @@ UTF-8 3.8.1 - 11 - 11 + 17 + 17 @@ -42,7 +65,7 @@ com.google.cloud.functions functions-framework-api - 1.1.0 + 2.0.1 @@ -67,7 +90,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.0 + 3.2.1 attach-sources @@ -80,7 +103,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.6.3 + 3.12.0 attach-javadocs @@ -93,7 +116,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + 3.2.8 sign-artifacts @@ -105,18 +128,17 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 true - sonatype-nexus-snapshots - https://oss.sonatype.org/ - true + sonatype-central-portal + https://central.sonatype.com/repository/maven-snapshots/ - \ No newline at end of file + diff --git a/invoker/testfunction/pom.xml b/invoker/testfunction/pom.xml index a19349ce..541eb9a8 100644 --- a/invoker/testfunction/pom.xml +++ b/invoker/testfunction/pom.xml @@ -4,22 +4,23 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.3.2-SNAPSHOT + 2.0.2-SNAPSHOT Example GCF Function Jar An example of a GCF function packaged into a jar. We use this in tests. + https://github.com/GoogleCloudPlatform/functions-framework-java com.google.cloud.functions functions-framework-api - 1.1.0 + 2.0.0