diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index de46a8d..5b95308 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -1,4 +1,4 @@ -name: Build and Push Docker Image +name: CI Pipeline on: push: @@ -15,8 +15,25 @@ env: IMAGE_NAME: enterprise-openldap jobs: + # Stage 1: Lint Dockerfile + lint: + runs-on: docker + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Lint Dockerfile with hadolint + run: | + docker run --rm -i hadolint/hadolint < Dockerfile || { + echo "::warning::Dockerfile linting found issues (non-blocking)" + } + + # Stage 2: Build image build: runs-on: docker + needs: lint + outputs: + image_tag: ${{ steps.version.outputs.VERSION }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -25,47 +42,165 @@ jobs: id: version run: | if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - # Extract version from tag (v1.0.0 -> 1.0.0) VERSION="${GITHUB_REF#refs/tags/v}" - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - echo "TAGS=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == refs/heads/main ]]; then - # Use short SHA for main branch - SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) - echo "VERSION=$SHORT_SHA" >> $GITHUB_OUTPUT - echo "TAGS=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHORT_SHA" >> $GITHUB_OUTPUT else - # Pull request - just use SHA - SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) - echo "VERSION=$SHORT_SHA" >> $GITHUB_OUTPUT - echo "TAGS=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-$SHORT_SHA" >> $GITHUB_OUTPUT + VERSION="$(echo "$GITHUB_SHA" | cut -c1-7)" fi - - - name: Log in to Docker Registry - if: github.event_name != 'pull_request' - run: | - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" - name: Build Docker image run: | - docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} . + docker build -t ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} . + docker tag ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} ${{ env.IMAGE_NAME }}:test - - name: Tag additional versions - if: github.event_name != 'pull_request' + - name: Save image for subsequent jobs + run: | + mkdir -p /tmp/images + docker save ${{ env.IMAGE_NAME }}:test -o /tmp/images/image.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: docker-image + path: /tmp/images/image.tar + retention-days: 1 + + # Stage 3: Integration tests + test: + runs-on: docker + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: docker-image + path: /tmp/images + + - name: Load Docker image + run: | + docker load -i /tmp/images/image.tar + + - name: Run integration tests + run: | + chmod +x tests/test-container.sh + ./tests/test-container.sh + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }}:test + CONTAINER_NAME: openldap-ci-test + + # Stage 4: Security scan + security-scan: + runs-on: docker + needs: build + steps: + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: docker-image + path: /tmp/images + + - name: Load Docker image + run: | + docker load -i /tmp/images/image.tar + + - name: Scan image with Trivy + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:latest image \ + --severity HIGH,CRITICAL \ + --exit-code 0 \ + --no-progress \ + ${{ env.IMAGE_NAME }}:test + + - name: Scan for critical vulnerabilities (blocking) + run: | + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:latest image \ + --severity CRITICAL \ + --exit-code 1 \ + --no-progress \ + --ignore-unfixed \ + ${{ env.IMAGE_NAME }}:test || { + echo "::error::Critical vulnerabilities found!" + exit 1 + } + + # Stage 5: Push to registry + push: + runs-on: docker + needs: [test, security-scan] + if: github.event_name != 'pull_request' + outputs: + version: ${{ steps.version.outputs.VERSION }} + full_image: ${{ steps.version.outputs.FULL_IMAGE }} + steps: + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: docker-image + path: /tmp/images + + - name: Load Docker image + run: | + docker load -i /tmp/images/image.tar + + - name: Determine version and tags + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + # For releases, tag with version, major.minor, and latest + MAJOR=$(echo $VERSION | cut -d. -f1) + MINOR=$(echo $VERSION | cut -d. -f2) + TAGS="${VERSION},${MAJOR}.${MINOR},latest" + else + VERSION="$(echo "$GITHUB_SHA" | cut -c1-7)" + TAGS="${VERSION},latest" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "TAGS=$TAGS" >> $GITHUB_OUTPUT + echo "FULL_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}" >> $GITHUB_OUTPUT + + - name: Log in to Docker Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + + - name: Tag and push images run: | IFS=',' read -ra TAGS <<< "${{ steps.version.outputs.TAGS }}" for TAG in "${TAGS[@]}"; do - docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} "$TAG" - done - - - name: Push Docker image - if: github.event_name != 'pull_request' - run: | - IFS=',' read -ra TAGS <<< "${{ steps.version.outputs.TAGS }}" - for TAG in "${TAGS[@]}"; do - docker push "$TAG" + docker tag ${{ env.IMAGE_NAME }}:test ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG + echo "Pushed: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$TAG" done - name: Logout from registry - if: always() && github.event_name != 'pull_request' + if: always() run: docker logout ${{ env.REGISTRY }} || true + + # Stage 6: Update CD pipeline (trigger deployment) + update-cd: + runs-on: docker + needs: push + if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') + steps: + - name: Trigger CD pipeline + run: | + echo "==============================================" + echo " Ready to update CD pipeline" + echo "==============================================" + echo "New version: ${{ needs.push.outputs.version }}" + echo "Full image: ${{ needs.push.outputs.full_image }}" + echo "" + echo "TODO: Add step to update version in CD repository" + echo "This could be:" + echo " - Update docker-compose.yml in infra repo" + echo " - Update Helm values" + echo " - Trigger ArgoCD sync" + echo "==============================================" diff --git a/tests/test-container.sh b/tests/test-container.sh new file mode 100755 index 0000000..94371da --- /dev/null +++ b/tests/test-container.sh @@ -0,0 +1,414 @@ +#!/bin/sh +# +# OpenLDAP Container Integration Tests +# Runs a series of tests to verify the container works correctly +# + +set -e + +# Configuration +CONTAINER_NAME="${CONTAINER_NAME:-openldap-test}" +IMAGE_NAME="${IMAGE_NAME:-enterprise-openldap:test}" +LDAP_DOMAIN="${LDAP_DOMAIN:-test.local}" +LDAP_ORGANISATION="${LDAP_ORGANISATION:-Test Organisation}" +LDAP_ADMIN_PASSWORD="${LDAP_ADMIN_PASSWORD:-testpassword123}" +LDAP_BASE_DN="dc=test,dc=local" +MAX_WAIT="${MAX_WAIT:-60}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Counters +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Helper functions +log_info() { + echo "${YELLOW}[INFO]${NC} $1" +} + +log_pass() { + echo "${GREEN}[PASS]${NC} $1" + TESTS_PASSED=$((TESTS_PASSED + 1)) +} + +log_fail() { + echo "${RED}[FAIL]${NC} $1" + TESTS_FAILED=$((TESTS_FAILED + 1)) +} + +cleanup() { + log_info "Cleaning up test container..." + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true +} + +# Ensure cleanup on exit +trap cleanup EXIT + +# Start the container +start_container() { + log_info "Starting test container with image: $IMAGE_NAME" + + docker run -d \ + --name "$CONTAINER_NAME" \ + -e LDAP_DOMAIN="$LDAP_DOMAIN" \ + -e LDAP_ORGANISATION="$LDAP_ORGANISATION" \ + -e LDAP_ADMIN_PASSWORD="$LDAP_ADMIN_PASSWORD" \ + -e LDAP_TLS_ENABLED=false \ + -e LDAP_CREATE_SERVICE_ACCOUNTS=true \ + -e LDAP_LOG_LEVEL=256 \ + "$IMAGE_NAME" + + if [ $? -eq 0 ]; then + log_pass "Container started" + else + log_fail "Container failed to start" + return 1 + fi +} + +# Wait for LDAP to be ready +wait_for_ldap() { + log_info "Waiting for LDAP to be ready (max ${MAX_WAIT}s)..." + + local count=0 + while [ $count -lt $MAX_WAIT ]; do + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost -b "" -s base "objectClass=*" >/dev/null 2>&1; then + log_pass "LDAP is responding" + return 0 + fi + count=$((count + 1)) + sleep 1 + done + + log_fail "LDAP did not become ready within ${MAX_WAIT}s" + log_info "Container logs:" + docker logs "$CONTAINER_NAME" 2>&1 | tail -50 + return 1 +} + +# Test: Verify base DN exists +test_base_dn() { + log_info "Testing base DN exists..." + + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "$LDAP_BASE_DN" -s base "objectClass=*" >/dev/null 2>&1; then + log_pass "Base DN exists and admin can bind" + else + log_fail "Base DN test failed" + return 1 + fi +} + +# Test: Verify organizational units +test_organizational_units() { + log_info "Testing organizational units..." + + local ous="People Groups Services Policies" + local failed=0 + + for ou in $ous; do + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "ou=$ou,$LDAP_BASE_DN" -s base "objectClass=organizationalUnit" 2>/dev/null | grep -q "dn: ou=$ou"; then + log_pass "OU=$ou exists" + else + log_fail "OU=$ou not found" + failed=1 + fi + done + + return $failed +} + +# Test: Verify schemas are loaded +test_schemas() { + log_info "Testing schemas are loaded..." + + local schemas="core cosine inetorgperson" + local failed=0 + + # Get loaded schemas + local loaded_schemas=$(docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "cn=schema,cn=config" -s one "objectClass=*" cn 2>/dev/null | grep "^cn:" | sed 's/cn: {[0-9]*}//' | tr -d ' ') + + for schema in $schemas; do + if echo "$loaded_schemas" | grep -qi "$schema"; then + log_pass "Schema '$schema' is loaded" + else + log_fail "Schema '$schema' not found" + failed=1 + fi + done + + return $failed +} + +# Test: Verify overlays are configured +test_overlays() { + log_info "Testing overlays are configured..." + + local overlays="memberof refint unique ppolicy" + local failed=0 + + for overlay in $overlays; do + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "cn=config" "olcOverlay=$overlay" 2>/dev/null | grep -q "olcOverlay.*$overlay"; then + log_pass "Overlay '$overlay' is configured" + else + log_fail "Overlay '$overlay' not found" + failed=1 + fi + done + + return $failed +} + +# Test: Verify service accounts exist +test_service_accounts() { + log_info "Testing service accounts..." + + local services="keycloak nextcloud gitea postfix dovecot sssd" + local failed=0 + + for service in $services; do + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "cn=$service,ou=Services,$LDAP_BASE_DN" -s base "objectClass=*" 2>/dev/null | grep -q "dn: cn=$service"; then + log_pass "Service account '$service' exists" + else + log_fail "Service account '$service' not found" + failed=1 + fi + done + + return $failed +} + +# Test: Add a user +test_add_user() { + log_info "Testing add user..." + + local user_ldif=$(cat </dev/null 2>&1; then + log_pass "User 'testuser' created" + else + log_fail "Failed to create user 'testuser'" + return 1 + fi +} + +# Test: Add a group with member +test_add_group() { + log_info "Testing add group with member..." + + local group_ldif=$(cat </dev/null 2>&1; then + log_pass "Group 'testgroup' created with member" + else + log_fail "Failed to create group 'testgroup'" + return 1 + fi +} + +# Test: Verify memberOf overlay works +test_memberof_overlay() { + log_info "Testing memberOf overlay..." + + # Check if user has memberOf attribute + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "uid=testuser,ou=People,$LDAP_BASE_DN" memberOf 2>/dev/null | grep -q "memberOf: cn=testgroup"; then + log_pass "memberOf attribute correctly set on user" + else + log_fail "memberOf attribute not found on user" + return 1 + fi +} + +# Test: User authentication +test_user_authentication() { + log_info "Testing user authentication..." + + if docker exec "$CONTAINER_NAME" ldapwhoami -x -H ldap://localhost \ + -D "uid=testuser,ou=People,$LDAP_BASE_DN" -w "testuser123" 2>/dev/null | grep -q "testuser"; then + log_pass "User authentication successful" + else + log_fail "User authentication failed" + return 1 + fi +} + +# Test: Service account can search +test_service_account_search() { + log_info "Testing service account can search users..." + + # Get keycloak password from the container + local keycloak_pass=$(docker exec "$CONTAINER_NAME" cat /var/lib/openldap/service-passwords.txt 2>/dev/null | grep keycloak | cut -d: -f2 | tr -d ' ') + + if [ -z "$keycloak_pass" ]; then + log_fail "Could not retrieve keycloak service account password" + return 1 + fi + + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=keycloak,ou=Services,$LDAP_BASE_DN" -w "$keycloak_pass" \ + -b "ou=People,$LDAP_BASE_DN" "(uid=testuser)" uid 2>/dev/null | grep -q "uid: testuser"; then + log_pass "Service account can search users" + else + log_fail "Service account search failed" + return 1 + fi +} + +# Test: Unique overlay (duplicate uid should fail) +test_unique_overlay() { + log_info "Testing unique overlay (duplicate uid should be rejected)..." + + local dup_ldif=$(cat <&1 | grep -qi "constraint violation\|already exists\|unique"; then + log_pass "Unique overlay rejected duplicate uid" + else + # Check if it was rejected by checking entry doesn't exist + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "uid=testuser2,ou=People,$LDAP_BASE_DN" 2>/dev/null | grep -q "numEntries: 0\|No such object"; then + log_pass "Unique overlay rejected duplicate uid" + else + log_fail "Unique overlay did not reject duplicate uid" + return 1 + fi + fi +} + +# Test: Refint overlay (delete user should remove from group) +test_refint_overlay() { + log_info "Testing refint overlay (user deletion updates group)..." + + # Delete the test user + if docker exec "$CONTAINER_NAME" ldapdelete -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + "uid=testuser,ou=People,$LDAP_BASE_DN" >/dev/null 2>&1; then + + # Check if user was removed from group + if docker exec "$CONTAINER_NAME" ldapsearch -x -H ldap://localhost \ + -D "cn=admin,$LDAP_BASE_DN" -w "$LDAP_ADMIN_PASSWORD" \ + -b "cn=testgroup,ou=Groups,$LDAP_BASE_DN" member 2>/dev/null | grep -q "member: uid=testuser"; then + log_fail "Refint overlay did not remove user from group" + return 1 + else + log_pass "Refint overlay removed user from group" + fi + else + log_fail "Failed to delete test user" + return 1 + fi +} + +# Main test execution +main() { + echo "==============================================" + echo " OpenLDAP Container Integration Tests" + echo "==============================================" + echo "" + + # Setup + cleanup + start_container || exit 1 + wait_for_ldap || exit 1 + + echo "" + echo "--- Basic Structure Tests ---" + test_base_dn + test_organizational_units + + echo "" + echo "--- Schema Tests ---" + test_schemas + + echo "" + echo "--- Overlay Configuration Tests ---" + test_overlays + + echo "" + echo "--- Service Account Tests ---" + test_service_accounts + + echo "" + echo "--- Functional Tests ---" + test_add_user + test_add_group + test_memberof_overlay + test_user_authentication + test_service_account_search + test_unique_overlay + test_refint_overlay + + echo "" + echo "==============================================" + echo " Test Results" + echo "==============================================" + echo " ${GREEN}Passed: $TESTS_PASSED${NC}" + echo " ${RED}Failed: $TESTS_FAILED${NC}" + echo "==============================================" + + if [ $TESTS_FAILED -gt 0 ]; then + echo "" + log_info "Container logs (last 30 lines):" + docker logs "$CONTAINER_NAME" 2>&1 | tail -30 + exit 1 + fi + + exit 0 +} + +main "$@"