# The contents of this file are subject to the terms of the Common Development and # Distribution License (the License). You may not use this file except in compliance with the # License. # # You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the # specific language governing permission and limitations under the License. # # When distributing Covered Software, include this CDDL Header Notice in each file and include # the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL # Header, with the fields enclosed by brackets [] replaced by your own identifying # information: "Portions copyright [year] [name of copyright owner]". # # Copyright 2026 3A Systems, LLC. name: "Benchmark" # Benchmark: OpenDJ vs OpenLDAP. # Runs manually (workflow_dispatch) and automatically after the "Release" workflow completes. # Starts the latest OpenLDAP and OpenDJ from Docker (image tags overridable via inputs), # benchmarks OpenLDAP first then OpenDJ with Apache JMeter, and writes versions + comparison # table + comparative charts to the job summary. Full JMeter HTML dashboards are uploaded as # the "jmeter-reports" artifact. on: workflow_dispatch: inputs: openldap_image: description: "OpenLDAP Docker image" default: "osixia/openldap:latest" opendj_image: description: "OpenDJ Docker image" default: "openidentityplatform/opendj:latest" threads: description: "Concurrent JMeter threads" default: "200" duration: description: "Test duration per server (seconds)" default: "300" rampup: description: "Ramp-up period (seconds)" default: "0" jmeter_version: description: "Apache JMeter version" default: "5.6.3" workflow_run: workflows: ["Release"] types: [completed] permissions: contents: read concurrency: group: benchmark-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: benchmark: # Manual runs always proceed; chained runs only after a successful Release. if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest env: # `${{ inputs.X || 'default' }}` so workflow_run (which carries no inputs) falls back. OPENLDAP_IMAGE: ${{ inputs.openldap_image || 'osixia/openldap:latest' }} OPENDJ_IMAGE: ${{ inputs.opendj_image || 'openidentityplatform/opendj:latest' }} THREADS: ${{ inputs.threads || '200' }} DURATION: ${{ inputs.duration || '300' }} RAMPUP: ${{ inputs.rampup || '0' }} JMETER: ${{ inputs.jmeter_version || '5.6.3' }} BENCHPW: benchPass1 BASEDN: dc=example,dc=com # Exclude the cached admin connection bind from the dashboard / statistics.json. SAMPLE_FILTER: '^(?!ADMIN_CONNECT).*' HEAP: -Xms1g -Xmx2g steps: - uses: actions/checkout@v6 - name: Cache JMeter uses: actions/cache@v5 with: path: ~/jmeter key: jmeter-${{ env.JMETER }} - name: Install tooling (ldap-utils + JMeter) run: | sudo apt-get update sudo apt-get install -y ldap-utils if [ ! -x "$HOME/jmeter/apache-jmeter-$JMETER/bin/jmeter" ]; then mkdir -p "$HOME/jmeter" curl -fsSL "https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-$JMETER.tgz" -o /tmp/jmeter.tgz tar -xzf /tmp/jmeter.tgz -C "$HOME/jmeter" fi echo "JMETER_BIN=$HOME/jmeter/apache-jmeter-$JMETER/bin/jmeter" >> "$GITHUB_ENV" - name: Start OpenLDAP run: | docker run -d --name openldap -p 2389:389 \ -e LDAP_ORGANISATION="Example" \ -e LDAP_DOMAIN="example.com" \ -e LDAP_ADMIN_PASSWORD="password" \ -e LDAP_TLS=false \ "$OPENLDAP_IMAGE" - name: Configure OpenLDAP (SSHA-256 hash-on-write, seed) run: | wait_ldap() { for i in $(seq 1 90); do ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \ -b "$BASEDN" -s base dn >/dev/null 2>&1 && { echo "OpenLDAP is up"; return 0; } sleep 2 done } le() { docker exec -i openldap "$@" -Y EXTERNAL -H ldapi:/// >/dev/null 2>&1 || true; } wait_ldap # Load pw-sha2 (provides {SSHA256}) and ppolicy (overlay) modules; osixia ships both # in /usr/lib/ldap. Create the module list entry or append the values, then restart so # the modules are active in the running slapd. printf 'dn: cn=module{0},cn=config\nobjectClass: olcModuleList\nolcModuleLoad: pw-sha2\nolcModuleLoad: ppolicy\n' | le ldapadd printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: pw-sha2\n' | le ldapmodify printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: ppolicy\n' | le ldapmodify docker restart openldap wait_ldap # Make slapd hash cleartext userPassword with {SSHA256} on a plain modify: set the global # password-hash and enable the ppolicy overlay's hash_cleartext on the mdb database. printf 'dn: olcDatabase={-1}frontend,cn=config\nchangetype: modify\nreplace: olcPasswordHash\nolcPasswordHash: {SSHA256}\n' | le ldapmodify DBDN="$(docker exec openldap ldapsearch -Y EXTERNAL -H ldapi:/// -LLL -b cn=config '(olcDatabase=*mdb)' dn 2>/dev/null | sed -n 's/^dn: //p' | head -1)" [ -n "$DBDN" ] || DBDN="olcDatabase={1}mdb,cn=config" printf 'dn: olcOverlay=ppolicy,%s\nobjectClass: olcOverlayConfig\nobjectClass: olcPPolicyConfig\nolcOverlay: ppolicy\nolcPPolicyHashCleartext: TRUE\n' "$DBDN" | le ldapadd # Seed ou=People (after the restart so it survives any re-init). ldapadd -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \ -f .github/benchmark/people.ldif || true - name: Capture OpenLDAP version run: | # No `| head -1`: head closes the pipe early, slapd -VV then gets SIGPIPE and under # `pipefail` fails the step with exit 141. Capture fully, take the first line in bash. ver="$(docker exec openldap sh -c '/usr/sbin/slapd -VV 2>&1 || slapd -VV 2>&1' || true)" ver="${ver%%$'\n'*}" [ -n "$ver" ] || ver="$OPENLDAP_IMAGE" echo "OpenLDAP: $ver" { echo "OPENLDAP_VER<> "$GITHUB_ENV" - name: Benchmark OpenLDAP run: | rm -rf openldap openldap.jtl HEAP="$HEAP" "$JMETER_BIN" -n -t .github/benchmark/benchmark.jmx \ -Jhost=localhost -Jport=2389 \ -Jbasedn="$BASEDN" \ -Jadminbinddn="cn=admin,$BASEDN" -Jadminbindpw=password \ -Jbenchpw="$BENCHPW" \ -Jthreads="$THREADS" -Jduration="$DURATION" -Jrampup="$RAMPUP" \ -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \ -l openldap.jtl -e -o openldap - name: Stop OpenLDAP if: always() run: | mkdir -p logs/openldap docker logs openldap > logs/openldap/server.log 2>&1 || true # osixia mostly logs to stdout (captured above); grab the in-container /var/log too. docker cp openldap:/var/log logs/openldap/var-log 2>/dev/null || true tail -n 100 logs/openldap/server.log || true docker rm -f openldap || true - name: Start OpenDJ run: | docker run -d --name opendj -p 1389:1389 \ -e ROOT_PASSWORD=password \ -e BASE_DN="$BASEDN" \ -e ADD_BASE_ENTRY=--addBaseEntry \ "$OPENDJ_IMAGE" - name: Wait for OpenDJ + seed ou=People run: | for i in $(seq 1 90); do if ldapsearch -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \ -b "$BASEDN" -s base dn >/dev/null 2>&1; then echo "OpenDJ is up"; break fi sleep 2 done ldapadd -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \ -f .github/benchmark/people.ldif || true - name: Configure OpenDJ password policy (SSHA-256 hash-on-write) run: | # Hash cleartext userPassword with Salted SHA-256 on write, matching OpenLDAP. docker exec opendj /opt/opendj/bin/dsconfig set-password-policy-prop \ --policy-name "Default Password Policy" \ --set default-password-storage-scheme:"Salted SHA-256" \ --hostname localhost --port 4444 \ --bindDN "cn=Directory Manager" --bindPassword password \ --trustAll --no-prompt - name: Capture OpenDJ version run: | # `|| true` guards the bind so a transient ldapsearch failure can't trip pipefail; # sed reads all input (no early pipe close), so no SIGPIPE here. ver="$( { ldapsearch -x -LLL -H ldap://localhost:1389 -D 'cn=Directory Manager' -w password \ -b '' -s base fullVendorVersion 2>/dev/null || true; } | sed -n 's/^fullVendorVersion: //p')" [ -n "$ver" ] || ver="$OPENDJ_IMAGE" echo "OpenDJ: $ver" { echo "OPENDJ_VER<> "$GITHUB_ENV" - name: Benchmark OpenDJ run: | rm -rf opendj opendj.jtl HEAP="$HEAP" "$JMETER_BIN" -n -t .github/benchmark/benchmark.jmx \ -Jhost=localhost -Jport=1389 \ -Jbasedn="$BASEDN" \ -Jadminbinddn="cn=Directory Manager" -Jadminbindpw=password \ -Jbenchpw="$BENCHPW" \ -Jthreads="$THREADS" -Jduration="$DURATION" -Jrampup="$RAMPUP" \ -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \ -l opendj.jtl -e -o opendj - name: Stop OpenDJ if: always() run: | mkdir -p logs/opendj docker logs opendj > logs/opendj/server.log 2>&1 || true # OpenDJ's internal log directory (errors, access, replication, server.out, ...). docker cp opendj:/opt/opendj/data/logs logs/opendj/internal 2>/dev/null \ || docker cp opendj:/opt/opendj/logs logs/opendj/internal 2>/dev/null || true tail -n 100 logs/opendj/server.log || true docker rm -f opendj || true # ---------------------------------------------------------------- Report - name: Build job summary run: | bash .github/benchmark/summary.sh \ openldap/statistics.json opendj/statistics.json \ "$OPENLDAP_VER" "$OPENDJ_VER" \ "$OPENLDAP_IMAGE" "$OPENDJ_IMAGE" >> "$GITHUB_STEP_SUMMARY" - name: Upload JMeter reports if: always() uses: actions/upload-artifact@v7 with: name: jmeter-reports path: | openldap/ opendj/ *.jtl if-no-files-found: warn retention-days: 90 - name: Upload OpenLDAP logs if: always() uses: actions/upload-artifact@v7 with: name: logs-openldap path: logs/openldap/ if-no-files-found: warn retention-days: 90 - name: Upload OpenDJ logs if: always() uses: actions/upload-artifact@v7 with: name: logs-opendj path: logs/opendj/ if-no-files-found: warn retention-days: 90