diff --git a/.github/workflows/ephemeral.yml b/.github/workflows/ephemeral.yml new file mode 100644 index 0000000..3cd95d9 --- /dev/null +++ b/.github/workflows/ephemeral.yml @@ -0,0 +1,357 @@ +name: PR Ephemeral Environment + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + pull-requests: write + +env: + REGISTRY: registry.fullstack.pw + VAULT_ADDR: https://vault.fullstack.pw + +jobs: + manage-environment: + runs-on: self-hosted + outputs: + cluster_name: ${{ steps.cluster-info.outputs.cluster_name }} + writer_dns: ${{ steps.cluster-info.outputs.writer_dns }} + enqueuer_dns: ${{ steps.cluster-info.outputs.enqueuer_dns }} + memorizer_dns: ${{ steps.cluster-info.outputs.memorizer_dns }} + frontend_dns: ${{ steps.cluster-info.outputs.frontend_dns }} + deployment_status: ${{ steps.deployment-status.outputs.status }} + steps: + - name: Set cluster info + id: cluster-info + run: | + PR_NUM="${{ github.event.pull_request.number }}" + CLUSTER_NAME="pr-demo-apps-${PR_NUM}" + WRITER_DNS="pr-${PR_NUM}-writer.ephemeral.fullstack.pw" + ENQUEUER_DNS="pr-${PR_NUM}-enqueuer.ephemeral.fullstack.pw" + MEMORIZER_DNS="pr-${PR_NUM}-memorizer.ephemeral.fullstack.pw" + FRONTEND_DNS="pr-${PR_NUM}-ascii-frontend.ephemeral.fullstack.pw" + + echo "cluster_name=$CLUSTER_NAME" >> $GITHUB_OUTPUT + echo "writer_dns=$WRITER_DNS" >> $GITHUB_OUTPUT + echo "enqueuer_dns=$ENQUEUER_DNS" >> $GITHUB_OUTPUT + echo "memorizer_dns=$MEMORIZER_DNS" >> $GITHUB_OUTPUT + echo "frontend_dns=$FRONTEND_DNS" >> $GITHUB_OUTPUT + + - name: Checkout infra repo + uses: actions/checkout@v4 + with: + repository: fullstack-pw/infra + token: ${{ secrets.RUNNER }} + path: infra + + - name: Check if cluster exists + id: check-cluster + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + if kubectl get cluster "$CLUSTER_NAME" -n "$CLUSTER_NAME" --context tools --kubeconfig /home/runner/.kube/config &>/dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Cluster $CLUSTER_NAME already exists" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Cluster $CLUSTER_NAME does not exist" + fi + + # ==================== CREATE/UPDATE ENVIRONMENT ==================== + - name: Check IP pool capacity + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + cd infra + AVAILABLE=$(./clusters/scripts/ip_pool_manager.sh check-capacity) + CAPACITY_RESULT=$? + echo "Capacity available: $AVAILABLE slots free" + if [ "$CAPACITY_RESULT" -ne 0 ]; then + echo "::error::IP pool exhausted. All 5 ephemeral cluster slots are in use (10 IPs / 2 per cluster). Please close old PRs." + exit 1 + fi + + - name: Allocate IP from pool + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + id: allocate + run: | + cd infra + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + CLUSTER_IP=$(./clusters/scripts/ip_pool_manager.sh allocate "$CLUSTER_NAME") + + # Calculate node IP (VIP + 1) - each cluster needs 2 IPs + IP_LAST_OCTET=$(echo "$CLUSTER_IP" | cut -d'.' -f4) + NODE_IP_LAST_OCTET=$((IP_LAST_OCTET + 1)) + NODE_IP="192.168.1.${NODE_IP_LAST_OCTET}" + + echo "ip=$CLUSTER_IP" >> $GITHUB_OUTPUT + echo "node_ip=$NODE_IP" >> $GITHUB_OUTPUT + echo "Allocated control plane VIP: $CLUSTER_IP" + echo "Node IP: $NODE_IP" + + - name: Render Cluster API manifest + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + cd infra + export CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + export CLUSTER_IP="${{ steps.allocate.outputs.ip }}" + export NODE_IP="${{ steps.allocate.outputs.node_ip }}" + export PR_NUMBER="${{ github.event.pull_request.number }}" + export REPOSITORY="demo-apps" + envsubst < ephemeral-clusters/cluster-api/k3s-cluster.yaml.tpl > /tmp/cluster.yaml + cat /tmp/cluster.yaml + + - name: Create cluster via Cluster API + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + kubectl apply -f /tmp/cluster.yaml --context tools --kubeconfig /home/runner/.kube/config + + - name: Copy Proxmox credentials to namespace + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + kubectl --context tools --kubeconfig /home/runner/.kube/config \ + get secret proxmox-credentials -n k3s-test -o json | \ + jq 'del(.metadata.namespace,.metadata.uid,.metadata.resourceVersion,.metadata.creationTimestamp,.metadata.ownerReferences,.metadata.finalizers)' | \ + kubectl --context tools --kubeconfig /home/runner/.kube/config apply -n "$CLUSTER_NAME" -f - || \ + echo "Warning: Could not copy proxmox-credentials (may already exist)" + + - name: Wait for cluster available + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + echo "Waiting for cluster $CLUSTER_NAME to be available..." + kubectl wait --for=condition=Available \ + cluster/$CLUSTER_NAME \ + -n $CLUSTER_NAME \ + --timeout=5m --context tools --kubeconfig /home/runner/.kube/config + + - name: Extract kubeconfig + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + kubectl get secret ${CLUSTER_NAME}-kubeconfig \ + -n $CLUSTER_NAME \ + -o jsonpath='{.data.value}' --context tools --kubeconfig /home/runner/.kube/config | base64 -d > /tmp/kubeconfig + + # Rename context to match workspace name for OpenTofu + ORIGINAL_CONTEXT=$(kubectl --kubeconfig /tmp/kubeconfig config current-context) + kubectl --kubeconfig /tmp/kubeconfig config rename-context "$ORIGINAL_CONTEXT" "$CLUSTER_NAME" + kubectl --kubeconfig /tmp/kubeconfig config use-context "$CLUSTER_NAME" + + - name: Apply ephemeral infrastructure with OpenTofu + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + env: + KUBECONFIG: /tmp/kubeconfig + run: | + cd infra + make ephemeral-init + make ephemeral-apply WORKSPACE=${{ steps.cluster-info.outputs.cluster_name }} + + # ==================== BUILD AND DEPLOY (PR opened/updated) ==================== + - name: Checkout app code + if: github.event.action != 'closed' + uses: actions/checkout@v4 + + - name: Build Docker images + if: github.event.action != 'closed' + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + + # Build all 4 images with correct context + docker build -t ${{ env.REGISTRY }}/library/writer:${PR_TAG} -f apps/writer/Dockerfile apps & + docker build -t ${{ env.REGISTRY }}/library/enqueuer:${PR_TAG} -f apps/enqueuer/Dockerfile apps & + docker build -t ${{ env.REGISTRY }}/library/memorizer:${PR_TAG} -f apps/memorizer/Dockerfile apps & + docker build -t ${{ env.REGISTRY }}/library/ascii-frontend:${PR_TAG} -f apps/ascii-frontend/Dockerfile apps & + + # Wait for all builds to complete + wait + echo "All images built successfully with tag: ${PR_TAG}" + + - name: Push Docker images + if: github.event.action != 'closed' + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + + # Login to Harbor + echo "${HARBOR_KEY}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin + + # Push all 4 images + docker push ${{ env.REGISTRY }}/library/writer:${PR_TAG} + docker push ${{ env.REGISTRY }}/library/enqueuer:${PR_TAG} + docker push ${{ env.REGISTRY }}/library/memorizer:${PR_TAG} + docker push ${{ env.REGISTRY }}/library/ascii-frontend:${PR_TAG} + + - name: Extract kubeconfig for deployment + if: github.event.action != 'closed' + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + kubectl get secret ${CLUSTER_NAME}-kubeconfig \ + -n $CLUSTER_NAME \ + -o jsonpath='{.data.value}' --context tools --kubeconfig /home/runner/.kube/config | base64 -d > /tmp/kubeconfig + + # Rename context to match workspace name for OpenTofu + ORIGINAL_CONTEXT=$(kubectl --kubeconfig /tmp/kubeconfig config current-context) + kubectl --kubeconfig /tmp/kubeconfig config rename-context "$ORIGINAL_CONTEXT" "$CLUSTER_NAME" + kubectl --kubeconfig /tmp/kubeconfig config use-context "$CLUSTER_NAME" + + - name: Deploy writer to ephemeral cluster + if: github.event.action != 'closed' + env: + KUBECONFIG: /tmp/kubeconfig + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + kubectl kustomize apps/writer/kustomize/overlays/ephemeral \ + | sed "s|${{ env.REGISTRY }}/library/writer:pr-will-be-replaced|${{ env.REGISTRY }}/library/writer:${PR_TAG}|" \ + | sed "s|dev.writer.fullstack.pw|${{ steps.cluster-info.outputs.writer_dns }}|g" \ + | kubectl apply -f - + + kubectl rollout status deployment/writer -n default --timeout=5m + + - name: Deploy enqueuer to ephemeral cluster + if: github.event.action != 'closed' + env: + KUBECONFIG: /tmp/kubeconfig + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + kubectl kustomize apps/enqueuer/kustomize/overlays/ephemeral \ + | sed "s|${{ env.REGISTRY }}/library/enqueuer:pr-will-be-replaced|${{ env.REGISTRY }}/library/enqueuer:${PR_TAG}|" \ + | sed "s|dev.enqueuer.fullstack.pw|${{ steps.cluster-info.outputs.enqueuer_dns }}|g" \ + | kubectl apply -f - + + kubectl rollout status deployment/enqueuer -n default --timeout=5m + + - name: Deploy memorizer to ephemeral cluster + if: github.event.action != 'closed' + env: + KUBECONFIG: /tmp/kubeconfig + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + kubectl kustomize apps/memorizer/kustomize/overlays/ephemeral \ + | sed "s|${{ env.REGISTRY }}/library/memorizer:pr-will-be-replaced|${{ env.REGISTRY }}/library/memorizer:${PR_TAG}|" \ + | sed "s|dev.memorizer.fullstack.pw|${{ steps.cluster-info.outputs.memorizer_dns }}|g" \ + | kubectl apply -f - + + kubectl rollout status deployment/memorizer -n default --timeout=5m + + - name: Deploy ascii-frontend to ephemeral cluster + if: github.event.action != 'closed' + env: + KUBECONFIG: /tmp/kubeconfig + run: | + PR_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}" + kubectl kustomize apps/ascii-frontend/kustomize/overlays/ephemeral \ + | sed "s|${{ env.REGISTRY }}/library/ascii-frontend:pr-will-be-replaced|${{ env.REGISTRY }}/library/ascii-frontend:${PR_TAG}|" \ + | sed "s|dev.ascii-frontend.fullstack.pw|${{ steps.cluster-info.outputs.frontend_dns }}|g" \ + | kubectl apply -f - + + kubectl rollout status deployment/ascii-frontend -n default --timeout=5m + + - name: Set deployment status + id: deployment-status + if: github.event.action != 'closed' + run: echo "status=success" >> $GITHUB_OUTPUT + + - name: Post deployment info + if: github.event.action != 'closed' && steps.check-cluster.outputs.exists == 'false' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const clusterName = '${{ steps.cluster-info.outputs.cluster_name }}'; + const writerDns = '${{ steps.cluster-info.outputs.writer_dns }}'; + const enqueuerDns = '${{ steps.cluster-info.outputs.enqueuer_dns }}'; + const memorizerDns = '${{ steps.cluster-info.outputs.memorizer_dns }}'; + const frontendDns = '${{ steps.cluster-info.outputs.frontend_dns }}'; + const prTag = 'pr-${{ github.event.pull_request.number }}-${{ github.sha }}'; + + const body = `## Ephemeral Environment Ready + + Your ephemeral environment has been deployed with all 4 demo apps! + + ### Application URLs + - **Writer**: https://${writerDns} + - **Enqueuer**: https://${enqueuerDns} + - **Memorizer**: https://${memorizerDns} + - **ASCII Frontend**: https://${frontendDns} + + ### Details + **Cluster**: \`${clusterName}\` + **Image Tag**: \`${prTag}\` + + The environment will be automatically destroyed when this PR is closed. + `; + + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + + # ==================== CLEANUP ==================== + - name: Get kubeconfig for destruction + if: github.event.action == 'closed' || (failure() && steps.check-cluster.outputs.exists == 'false') + run: | + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + kubectl get secret ${CLUSTER_NAME}-kubeconfig \ + -n $CLUSTER_NAME \ + -o jsonpath='{.data.value}' --context tools --kubeconfig /home/runner/.kube/config | base64 -d > /tmp/kubeconfig || true + + # Rename context to match workspace name for OpenTofu + if [ -f /tmp/kubeconfig ]; then + ORIGINAL_CONTEXT=$(kubectl --kubeconfig /tmp/kubeconfig config current-context 2>/dev/null || echo "") + if [ -n "$ORIGINAL_CONTEXT" ]; then + kubectl --kubeconfig /tmp/kubeconfig config rename-context "$ORIGINAL_CONTEXT" "$CLUSTER_NAME" 2>/dev/null || true + kubectl --kubeconfig /tmp/kubeconfig config use-context "$CLUSTER_NAME" 2>/dev/null || true + fi + fi + + - name: Destroy ephemeral infrastructure + if: github.event.action == 'closed' || (failure() && steps.check-cluster.outputs.exists == 'false') + env: + KUBECONFIG: /tmp/kubeconfig + run: | + cd infra + CLUSTER_NAME="${{ steps.cluster-info.outputs.cluster_name }}" + + make ephemeral-init + make ephemeral-destroy WORKSPACE=${{ steps.cluster-info.outputs.cluster_name }} || true + + kubectl delete cluster "$CLUSTER_NAME" -n "$CLUSTER_NAME" --context tools --kubeconfig /home/runner/.kube/config || true + + ./clusters/scripts/ip_pool_manager.sh release "$CLUSTER_NAME" || true + + cypress-tests: + needs: manage-environment + if: github.event.action != 'closed' && needs.manage-environment.outputs.deployment_status == 'success' + runs-on: self-hosted + container: + image: cypress/included:13.6.4 + steps: + - name: Checkout demo-apps repository + uses: actions/checkout@v4 + + - name: Run Cypress tests + env: + WRITER_URL: https://${{ needs.manage-environment.outputs.writer_dns }} + ENQUEUER_URL: https://${{ needs.manage-environment.outputs.enqueuer_dns }} + MEMORIZER_URL: https://${{ needs.manage-environment.outputs.memorizer_dns }} + FRONTEND_URL: https://${{ needs.manage-environment.outputs.frontend_dns }} + TEST_ENV: ephemeral + ENVIRONMENT: ephemeral + CYPRESS_CACHE_FOLDER: /tmp/.cache/Cypress + run: | + echo "Running Cypress tests for demo-apps" + + # Install dependencies + npm ci + + # Run Cypress tests (if they exist) + if [ -d "cypress/e2e" ]; then + npx cypress run \ + --env WRITER_URL=${WRITER_URL},ENQUEUER_URL=${ENQUEUER_URL},MEMORIZER_URL=${MEMORIZER_URL},FRONTEND_URL=${FRONTEND_URL},TEST_ENV=${TEST_ENV} \ + --config baseUrl=${FRONTEND_URL} + else + echo "No Cypress tests found, skipping..." + fi diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 596d73e..e2112e5 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -1,235 +1,235 @@ -name: Multi-App CI/CD Pipeline - -on: - pull_request: - branches: - - main - types: - - opened - - synchronize - - reopened - paths: - - "apps/**" - push: - branches: - - main - paths: - - "apps/**" - -permissions: - contents: write - -jobs: - determine-app: - runs-on: self-hosted - outputs: - apps: ${{ steps.find-apps.outputs.apps }} - go_apps: ${{ steps.find-apps.outputs.go_apps }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Find changed apps - id: find-apps - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - # For push events, get changed files from the commit - CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }}) - else - # For PR events, get changed files in the PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }} HEAD) - fi - - APPS=() - GO_APPS=() - SEEN_APPS=() - - for FILE in $CHANGED_FILES; do - if [[ $FILE == apps/* && $FILE != apps/shared/* ]]; then - APP_PATH=$(echo $FILE | cut -d'/' -f1-2) - APP_NAME=$(echo $FILE | cut -d'/' -f2) - - # Check if this app is already in the list - if [[ ! " ${SEEN_APPS[@]} " =~ " $APP_NAME " ]]; then - SEEN_APPS+=("$APP_NAME") - - APP_ENTRY="{\"app_path\": \"$APP_PATH\", \"app_name\": \"$APP_NAME\"}" - APPS+=("$APP_ENTRY") - - # Add to go_apps if it's a Go project - if [[ -f "$APP_PATH/go.mod" ]]; then - GO_APPS+=("$APP_ENTRY") - fi - fi - fi - done - - # Format as JSON arrays for GitHub Actions - if [[ ${#APPS[@]} -eq 0 ]]; then - APPS_JSON="[]" - else - APPS_JSON=$(printf '%s\n' "${APPS[@]}" | jq -s . | jq -c .) - fi - - if [[ ${#GO_APPS[@]} -eq 0 ]]; then - GO_APPS_JSON="[]" - else - GO_APPS_JSON=$(printf '%s\n' "${GO_APPS[@]}" | jq -s . | jq -c .) - fi - - echo "Apps: $APPS_JSON" - echo "Go Apps: $GO_APPS_JSON" - echo "apps=$APPS_JSON" >> $GITHUB_OUTPUT - echo "go_apps=$GO_APPS_JSON" >> $GITHUB_OUTPUT - - go-tests: - needs: determine-app - if: needs.determine-app.outputs.go_apps != '[]' - strategy: - matrix: - app: ${{ fromJson(needs.determine-app.outputs.go_apps) }} - name: GO Tests for ${{ matrix.app.app_name }} - uses: fullstack-pw/pipelines/.github/workflows/go-tests.yml@main - with: - go-dir: ./${{ matrix.app.app_path }} - - docker-build-and-push: - needs: [determine-app] - if: needs.determine-app.outputs.apps != '[]' - strategy: - matrix: - app: ${{ fromJson(needs.determine-app.outputs.apps) }} - name: Build and Push ${{ matrix.app.app_name }} - uses: fullstack-pw/pipelines/.github/workflows/build-and-push.yml@main - with: - app-context: "./apps" - app-name: "${{ matrix.app.app_name }}" - app-dockerfile: "./apps/${{ matrix.app.app_name }}/Dockerfile" - - update-dev-kustomization: - needs: [determine-app, docker-build-and-push] - if: needs.determine-app.outputs.apps != '[]' && github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: self-hosted - strategy: - matrix: - app: ${{ fromJson(needs.determine-app.outputs.apps) }} - name: Deploy ${{ matrix.app.app_name }} to dev - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Update dev kustomization with new image tag - run: | - # Use full SHA to match the Docker tag from build-and-push.yml - VERSION="${{ github.sha }}" - KUSTOMIZATION_FILE="apps/${{ matrix.app.app_name }}/kustomize/overlays/dev/kustomization.yaml" - - if [ ! -f "$KUSTOMIZATION_FILE" ]; then - echo "Warning: $KUSTOMIZATION_FILE not found, skipping" - exit 0 - fi - - echo "Updating ${{ matrix.app.app_name }} dev to version: $VERSION" - sed -i "s/newTag: .*/newTag: $VERSION/" "$KUSTOMIZATION_FILE" - - git config user.name "GitHub Actions Bot" - git config user.email "actions@github.com" - git add "$KUSTOMIZATION_FILE" - git commit -m "chore(${{ matrix.app.app_name }}): deploy $VERSION to dev [skip ci]" || echo "No changes to commit" - git push || echo "Nothing to push" - -# dev-deploy: -# needs: [determine-app, docker-build-and-push] -# if: needs.determine-app.outputs.app_paths != '[]' +# name: Multi-App CI/CD Pipeline + +# on: +# pull_request: +# branches: +# - main +# types: +# - opened +# - synchronize +# - reopened +# paths: +# - "apps/**" +# push: +# branches: +# - main +# paths: +# - "apps/**" + +# permissions: +# contents: write + +# jobs: +# determine-app: +# runs-on: self-hosted +# outputs: +# apps: ${{ steps.find-apps.outputs.apps }} +# go_apps: ${{ steps.find-apps.outputs.go_apps }} +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 +# with: +# fetch-depth: 0 + +# - name: Find changed apps +# id: find-apps +# run: | +# if [[ "${{ github.event_name }}" == "push" ]]; then +# # For push events, get changed files from the commit +# CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }}) +# else +# # For PR events, get changed files in the PR +# CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }} HEAD) +# fi + +# APPS=() +# GO_APPS=() +# SEEN_APPS=() + +# for FILE in $CHANGED_FILES; do +# if [[ $FILE == apps/* && $FILE != apps/shared/* ]]; then +# APP_PATH=$(echo $FILE | cut -d'/' -f1-2) +# APP_NAME=$(echo $FILE | cut -d'/' -f2) + +# # Check if this app is already in the list +# if [[ ! " ${SEEN_APPS[@]} " =~ " $APP_NAME " ]]; then +# SEEN_APPS+=("$APP_NAME") + +# APP_ENTRY="{\"app_path\": \"$APP_PATH\", \"app_name\": \"$APP_NAME\"}" +# APPS+=("$APP_ENTRY") + +# # Add to go_apps if it's a Go project +# if [[ -f "$APP_PATH/go.mod" ]]; then +# GO_APPS+=("$APP_ENTRY") +# fi +# fi +# fi +# done + +# # Format as JSON arrays for GitHub Actions +# if [[ ${#APPS[@]} -eq 0 ]]; then +# APPS_JSON="[]" +# else +# APPS_JSON=$(printf '%s\n' "${APPS[@]}" | jq -s . | jq -c .) +# fi + +# if [[ ${#GO_APPS[@]} -eq 0 ]]; then +# GO_APPS_JSON="[]" +# else +# GO_APPS_JSON=$(printf '%s\n' "${GO_APPS[@]}" | jq -s . | jq -c .) +# fi + +# echo "Apps: $APPS_JSON" +# echo "Go Apps: $GO_APPS_JSON" +# echo "apps=$APPS_JSON" >> $GITHUB_OUTPUT +# echo "go_apps=$GO_APPS_JSON" >> $GITHUB_OUTPUT + +# go-tests: +# needs: determine-app +# if: needs.determine-app.outputs.go_apps != '[]' # strategy: # matrix: -# app: ${{ fromJson(needs.determine-app.outputs.apps) }} -# name: DEV deploy ${{ matrix.app.app_name }} -# uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main -# with: -# kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/dev" -# context: "dev" -# app-name: ${{ matrix.app.app_name }} - -# dev-cypress-tests: -# needs: dev-deploy -# name: DEV cypress -# uses: fullstack-pw/pipelines/.github/workflows/cypress.yml@main +# app: ${{ fromJson(needs.determine-app.outputs.go_apps) }} +# name: GO Tests for ${{ matrix.app.app_name }} +# uses: fullstack-pw/pipelines/.github/workflows/go-tests.yml@main # with: -# start: npm run cypress:dev -# env-vars: | -# { -# "ENQUEUER_URL": "https://dev.enqueuer.fullstack.pw", -# "ENVIRONMENT": "dev", -# "TEST_RETRIES": "2" -# } - -# stg-deploy: -# needs: [determine-app, dev-cypress-tests] -# if: needs.determine-app.outputs.app_paths != '[]' +# go-dir: ./${{ matrix.app.app_path }} + +# docker-build-and-push: +# needs: [determine-app] +# if: needs.determine-app.outputs.apps != '[]' # strategy: # matrix: # app: ${{ fromJson(needs.determine-app.outputs.apps) }} -# name: STG deploy ${{ matrix.app.app_name }} -# uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main -# with: -# kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/stg" -# context: "stg" -# app-name: ${{ matrix.app.app_name }} - -# stg-cypress-tests: -# needs: stg-deploy -# name: STG cypress -# uses: fullstack-pw/pipelines/.github/workflows/cypress.yml@main +# name: Build and Push ${{ matrix.app.app_name }} +# uses: fullstack-pw/pipelines/.github/workflows/build-and-push.yml@main # with: -# start: npm run cypress:stg -# env-vars: | -# { -# "ENQUEUER_URL": "https://stg.enqueuer.fullstack.pw", -# "WRITER_URL": "https://stg.writer.fullstack.pw", -# "MEMORIZER_URL": "https://stg.memorizer.fullstack.pw", -# "ENVIRONMENT": "stg", -# "TEST_RETRIES": "2" -# } - -# prod-deploy: -# needs: [determine-app, stg-cypress-tests] -# if: needs.determine-app.outputs.app_paths != '[]' && github.event_name == 'push' && github.ref == 'refs/heads/main' +# app-context: "./apps" +# app-name: "${{ matrix.app.app_name }}" +# app-dockerfile: "./apps/${{ matrix.app.app_name }}/Dockerfile" + +# update-dev-kustomization: +# needs: [determine-app, docker-build-and-push] +# if: needs.determine-app.outputs.apps != '[]' && github.event_name == 'push' && github.ref == 'refs/heads/main' +# runs-on: self-hosted # strategy: # matrix: # app: ${{ fromJson(needs.determine-app.outputs.apps) }} -# name: PROD deploy ${{ matrix.app.app_name }} -# uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main -# with: -# kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/prod" -# context: "prod" -# app-name: ${{ matrix.app.app_name }} - -# versioning: -# permissions: -# contents: write -# runs-on: self-hosted -# if: github.event_name == 'push' && github.ref == 'refs/heads/main' -# name: Versioning +# name: Deploy ${{ matrix.app.app_name }} to dev # steps: -# - name: Get Next Version -# id: semver -# uses: ietf-tools/semver-action@v1 -# with: -# token: ${{ github.token }} -# branch: main - -# - name: Create Release -# uses: ncipollo/release-action@v1.12.0 +# - name: Checkout code +# uses: actions/checkout@v4 # with: -# allowUpdates: true -# draft: false -# makeLatest: true -# tag: ${{ steps.semver.outputs.next }} -# body: Changelog Contents -# token: ${{ github.token }} +# token: ${{ secrets.GITHUB_TOKEN }} +# fetch-depth: 0 + +# - name: Update dev kustomization with new image tag +# run: | +# # Use full SHA to match the Docker tag from build-and-push.yml +# VERSION="${{ github.sha }}" +# KUSTOMIZATION_FILE="apps/${{ matrix.app.app_name }}/kustomize/overlays/dev/kustomization.yaml" + +# if [ ! -f "$KUSTOMIZATION_FILE" ]; then +# echo "Warning: $KUSTOMIZATION_FILE not found, skipping" +# exit 0 +# fi + +# echo "Updating ${{ matrix.app.app_name }} dev to version: $VERSION" +# sed -i "s/newTag: .*/newTag: $VERSION/" "$KUSTOMIZATION_FILE" + +# git config user.name "GitHub Actions Bot" +# git config user.email "actions@github.com" +# git add "$KUSTOMIZATION_FILE" +# git commit -m "chore(${{ matrix.app.app_name }}): deploy $VERSION to dev [skip ci]" || echo "No changes to commit" +# git push || echo "Nothing to push" + +# # dev-deploy: +# # needs: [determine-app, docker-build-and-push] +# # if: needs.determine-app.outputs.app_paths != '[]' +# # strategy: +# # matrix: +# # app: ${{ fromJson(needs.determine-app.outputs.apps) }} +# # name: DEV deploy ${{ matrix.app.app_name }} +# # uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main +# # with: +# # kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/dev" +# # context: "dev" +# # app-name: ${{ matrix.app.app_name }} + +# # dev-cypress-tests: +# # needs: dev-deploy +# # name: DEV cypress +# # uses: fullstack-pw/pipelines/.github/workflows/cypress.yml@main +# # with: +# # start: npm run cypress:dev +# # env-vars: | +# # { +# # "ENQUEUER_URL": "https://dev.enqueuer.fullstack.pw", +# # "ENVIRONMENT": "dev", +# # "TEST_RETRIES": "2" +# # } + +# # stg-deploy: +# # needs: [determine-app, dev-cypress-tests] +# # if: needs.determine-app.outputs.app_paths != '[]' +# # strategy: +# # matrix: +# # app: ${{ fromJson(needs.determine-app.outputs.apps) }} +# # name: STG deploy ${{ matrix.app.app_name }} +# # uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main +# # with: +# # kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/stg" +# # context: "stg" +# # app-name: ${{ matrix.app.app_name }} + +# # stg-cypress-tests: +# # needs: stg-deploy +# # name: STG cypress +# # uses: fullstack-pw/pipelines/.github/workflows/cypress.yml@main +# # with: +# # start: npm run cypress:stg +# # env-vars: | +# # { +# # "ENQUEUER_URL": "https://stg.enqueuer.fullstack.pw", +# # "WRITER_URL": "https://stg.writer.fullstack.pw", +# # "MEMORIZER_URL": "https://stg.memorizer.fullstack.pw", +# # "ENVIRONMENT": "stg", +# # "TEST_RETRIES": "2" +# # } + +# # prod-deploy: +# # needs: [determine-app, stg-cypress-tests] +# # if: needs.determine-app.outputs.app_paths != '[]' && github.event_name == 'push' && github.ref == 'refs/heads/main' +# # strategy: +# # matrix: +# # app: ${{ fromJson(needs.determine-app.outputs.apps) }} +# # name: PROD deploy ${{ matrix.app.app_name }} +# # uses: fullstack-pw/pipelines/.github/workflows/deploy-kustomize.yml@main +# # with: +# # kustomize-dir: "./${{ matrix.app.app_path }}/kustomize/overlays/prod" +# # context: "prod" +# # app-name: ${{ matrix.app.app_name }} + +# # versioning: +# # permissions: +# # contents: write +# # runs-on: self-hosted +# # if: github.event_name == 'push' && github.ref == 'refs/heads/main' +# # name: Versioning +# # steps: +# # - name: Get Next Version +# # id: semver +# # uses: ietf-tools/semver-action@v1 +# # with: +# # token: ${{ github.token }} +# # branch: main + +# # - name: Create Release +# # uses: ncipollo/release-action@v1.12.0 +# # with: +# # allowUpdates: true +# # draft: false +# # makeLatest: true +# # tag: ${{ steps.semver.outputs.next }} +# # body: Changelog Contents +# # token: ${{ github.token }} diff --git a/README.md b/README.md index 8d4bafb..f3b41df 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,33 @@ This project demonstrates a modern microservices architecture that processes mes The system consists of multiple microservices communicating via message queues: ![Arch](demo-apps.svg) + +### Complete Message Flow + +1. **User Request**: User accesses `ascii-frontend` and submits a query (e.g., "kubernetes") +2. **Enqueuer Processing**: + - Receives the query via REST API (`POST /add`) + - Searches Google Images using Chromium headless browser + - Picks the first suitable image URL + - Publishes message with image URL to NATS queue (`{ENV}.queue-{QUEUE_NAME}`) + - Subscribes to result queue (`{ENV}.result-queue`) to wait for ASCII art response +3. **Memorizer Processing**: + - Listens to NATS queue for new messages + - Downloads the image from the URL + - Generates ASCII art (terminal, file, and HTML formats) + - Stores the ASCII art output in Redis (with environment prefix: `{ENV}:{MESSAGE_ID}:ascii_*`) + - Publishes the result back to NATS result queue +4. **Enqueuer Response**: + - Receives ASCII art result from NATS result queue + - Returns complete response to the user (including image URL and ASCII art) +5. **Writer Storage**: + - Monitors Redis for new entries + - Retrieves processed messages with ASCII art + - Stores everything in PostgreSQL (environment-specific tables) + - Provides query endpoints for historical data + +### Technology Stack + - **Communication**: NATS for message queuing - **Storage**: Redis for temporary storage, PostgreSQL for persistent storage - **Deployment**: Kubernetes with Kustomize configurations diff --git a/apps/ascii-frontend/kustomize/ephemeral-base/deployment.yaml b/apps/ascii-frontend/kustomize/ephemeral-base/deployment.yaml new file mode 100644 index 0000000..34b034a --- /dev/null +++ b/apps/ascii-frontend/kustomize/ephemeral-base/deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ascii-frontend +spec: + replicas: 1 + selector: + matchLabels: + app: ascii-frontend + template: + metadata: + labels: + app: ascii-frontend + spec: + containers: + - name: ascii-frontend + image: registry.fullstack.pw/library/ascii-frontend:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 15 + periodSeconds: 20 diff --git a/apps/ascii-frontend/kustomize/ephemeral-base/ingress.yaml b/apps/ascii-frontend/kustomize/ephemeral-base/ingress.yaml new file mode 100644 index 0000000..0bff800 --- /dev/null +++ b/apps/ascii-frontend/kustomize/ephemeral-base/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ascii-frontend + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + external-dns.alpha.kubernetes.io/hostname: "endpoint" + cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: "traefik" + tls: + - hosts: + - "endpoint" + secretName: ascii-frontend-tls-ephemeral + rules: + - host: "endpoint" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ascii-frontend + port: + number: 80 diff --git a/apps/ascii-frontend/kustomize/ephemeral-base/kustomization.yaml b/apps/ascii-frontend/kustomize/ephemeral-base/kustomization.yaml new file mode 100644 index 0000000..df0f21c --- /dev/null +++ b/apps/ascii-frontend/kustomize/ephemeral-base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service.yaml + - deployment.yaml + - ingress.yaml + +namespace: default diff --git a/apps/ascii-frontend/kustomize/ephemeral-base/service.yaml b/apps/ascii-frontend/kustomize/ephemeral-base/service.yaml new file mode 100644 index 0000000..463be1f --- /dev/null +++ b/apps/ascii-frontend/kustomize/ephemeral-base/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: ascii-frontend +spec: + selector: + app: ascii-frontend + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: http + type: ClusterIP diff --git a/apps/ascii-frontend/kustomize/overlays/ephemeral/kustomization.yaml b/apps/ascii-frontend/kustomize/overlays/ephemeral/kustomization.yaml new file mode 100644 index 0000000..5ac92a0 --- /dev/null +++ b/apps/ascii-frontend/kustomize/overlays/ephemeral/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: default + +resources: + - ../../ephemeral-base + +images: + - name: registry.fullstack.pw/library/ascii-frontend + newTag: pr-will-be-replaced + +patches: + - patch: |- + - op: replace + path: /metadata/annotations/external-dns.alpha.kubernetes.io~1hostname + value: "dev.ascii-frontend.fullstack.pw" + - op: replace + path: /spec/tls/0/hosts/0 + value: "dev.ascii-frontend.fullstack.pw" + - op: replace + path: /spec/rules/0/host + value: "dev.ascii-frontend.fullstack.pw" + target: + kind: Ingress + name: ascii-frontend diff --git a/apps/enqueuer/kustomize/ephemeral-base/deployment.yaml b/apps/enqueuer/kustomize/ephemeral-base/deployment.yaml new file mode 100644 index 0000000..dc155ac --- /dev/null +++ b/apps/enqueuer/kustomize/ephemeral-base/deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: enqueuer +spec: + replicas: 1 + selector: + matchLabels: + app: enqueuer + template: + metadata: + labels: + app: enqueuer + spec: + containers: + - name: enqueuer + image: registry.fullstack.pw/library/enqueuer:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + env: + - name: ENV + value: "ephemeral" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: opentelemetry-collector.observability.svc.cluster.local:4317 + - name: NATS_URL + value: nats.fullstack.pw:4222 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 diff --git a/apps/enqueuer/kustomize/ephemeral-base/ingress.yaml b/apps/enqueuer/kustomize/ephemeral-base/ingress.yaml new file mode 100644 index 0000000..b216b21 --- /dev/null +++ b/apps/enqueuer/kustomize/ephemeral-base/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: enqueuer + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + external-dns.alpha.kubernetes.io/hostname: "endpoint" + cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: "traefik" + tls: + - hosts: + - "endpoint" + secretName: enqueuer-tls-ephemeral + rules: + - host: "endpoint" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: enqueuer + port: + number: 8080 diff --git a/apps/enqueuer/kustomize/ephemeral-base/kustomization.yaml b/apps/enqueuer/kustomize/ephemeral-base/kustomization.yaml new file mode 100644 index 0000000..df0f21c --- /dev/null +++ b/apps/enqueuer/kustomize/ephemeral-base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service.yaml + - deployment.yaml + - ingress.yaml + +namespace: default diff --git a/apps/enqueuer/kustomize/ephemeral-base/service.yaml b/apps/enqueuer/kustomize/ephemeral-base/service.yaml new file mode 100644 index 0000000..a06d818 --- /dev/null +++ b/apps/enqueuer/kustomize/ephemeral-base/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: enqueuer +spec: + selector: + app: enqueuer + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + type: ClusterIP diff --git a/apps/enqueuer/kustomize/overlays/ephemeral/kustomization.yaml b/apps/enqueuer/kustomize/overlays/ephemeral/kustomization.yaml new file mode 100644 index 0000000..6f1d580 --- /dev/null +++ b/apps/enqueuer/kustomize/overlays/ephemeral/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: default + +resources: + - ../../ephemeral-base + +images: + - name: registry.fullstack.pw/library/enqueuer + newTag: pr-will-be-replaced + +patches: + - patch: |- + - op: replace + path: /metadata/annotations/external-dns.alpha.kubernetes.io~1hostname + value: "dev.enqueuer.fullstack.pw" + - op: replace + path: /spec/tls/0/hosts/0 + value: "dev.enqueuer.fullstack.pw" + - op: replace + path: /spec/rules/0/host + value: "dev.enqueuer.fullstack.pw" + target: + kind: Ingress + name: enqueuer diff --git a/apps/memorizer/kustomize/ephemeral-base/deployment.yaml b/apps/memorizer/kustomize/ephemeral-base/deployment.yaml new file mode 100644 index 0000000..8ca5107 --- /dev/null +++ b/apps/memorizer/kustomize/ephemeral-base/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: memorizer +spec: + replicas: 1 + selector: + matchLabels: + app: memorizer + template: + metadata: + labels: + app: memorizer + spec: + containers: + - name: memorizer + image: registry.fullstack.pw/library/memorizer:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + env: + - name: ENV + value: "ephemeral" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: opentelemetry-collector.observability.svc.cluster.local:4317 + - name: QUEUE_NAMES + value: "queue-ephemeral" + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: cluster-secrets + key: REDIS_PASSWORD + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 diff --git a/apps/memorizer/kustomize/ephemeral-base/ingress.yaml b/apps/memorizer/kustomize/ephemeral-base/ingress.yaml new file mode 100644 index 0000000..7219366 --- /dev/null +++ b/apps/memorizer/kustomize/ephemeral-base/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: memorizer + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + external-dns.alpha.kubernetes.io/hostname: "endpoint" + cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: "traefik" + tls: + - hosts: + - "endpoint" + secretName: memorizer-tls-ephemeral + rules: + - host: "endpoint" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: memorizer + port: + number: 8080 diff --git a/apps/memorizer/kustomize/ephemeral-base/kustomization.yaml b/apps/memorizer/kustomize/ephemeral-base/kustomization.yaml new file mode 100644 index 0000000..df0f21c --- /dev/null +++ b/apps/memorizer/kustomize/ephemeral-base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service.yaml + - deployment.yaml + - ingress.yaml + +namespace: default diff --git a/apps/memorizer/kustomize/ephemeral-base/service.yaml b/apps/memorizer/kustomize/ephemeral-base/service.yaml new file mode 100644 index 0000000..6f157ad --- /dev/null +++ b/apps/memorizer/kustomize/ephemeral-base/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: memorizer +spec: + selector: + app: memorizer + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + type: ClusterIP diff --git a/apps/memorizer/kustomize/overlays/ephemeral/kustomization.yaml b/apps/memorizer/kustomize/overlays/ephemeral/kustomization.yaml new file mode 100644 index 0000000..4aa7840 --- /dev/null +++ b/apps/memorizer/kustomize/overlays/ephemeral/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: default + +resources: + - ../../ephemeral-base + +images: + - name: registry.fullstack.pw/library/memorizer + newTag: pr-will-be-replaced + +patches: + - patch: |- + - op: replace + path: /metadata/annotations/external-dns.alpha.kubernetes.io~1hostname + value: "dev.memorizer.fullstack.pw" + - op: replace + path: /spec/tls/0/hosts/0 + value: "dev.memorizer.fullstack.pw" + - op: replace + path: /spec/rules/0/host + value: "dev.memorizer.fullstack.pw" + target: + kind: Ingress + name: memorizer diff --git a/apps/memorizer/main.go b/apps/memorizer/main.go index 4747aa5..5f1d0d4 100644 --- a/apps/memorizer/main.go +++ b/apps/memorizer/main.go @@ -442,12 +442,17 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { span.SetAttributes(attribute.String("message.id", id)) - // Check if the message exists in Redis - exists, err := redisConn.Client().Exists(ctx, id).Result() + // Check for ASCII art keys instead of base message key + // The base message key is deleted by writer after processing, but ASCII art keys remain + terminalKey := redisConn.PrefixKey(id + ":ascii:terminal") + htmlKey := redisConn.PrefixKey(id + ":ascii:html") + + // Check if either ASCII art key exists (indicates memorizer processed the message) + exists, err := redisConn.Client().Exists(ctx, terminalKey, htmlKey).Result() if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "Redis query failed") - logger.Error(ctx, "Error checking if message exists", "error", err, "id", id) + logger.Error(ctx, "Error checking if message exists", "error", err, "id", id, "terminal_key", terminalKey, "html_key", htmlKey) http.Error(w, "Error checking message status", http.StatusInternalServerError) return } diff --git a/apps/writer/kustomize/ephemeral-base/deployment.yaml b/apps/writer/kustomize/ephemeral-base/deployment.yaml new file mode 100644 index 0000000..2cd0457 --- /dev/null +++ b/apps/writer/kustomize/ephemeral-base/deployment.yaml @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: writer +spec: + replicas: 1 + selector: + matchLabels: + app: writer + template: + metadata: + labels: + app: writer + spec: + containers: + - name: writer + image: registry.fullstack.pw/library/writer:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + env: + - name: ENV + value: "ephemeral" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: opentelemetry-collector.observability.svc.cluster.local:4317 + - name: QUEUE_NAMES + value: "ephemeral-queue" + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: cluster-secrets + key: REDIS_PASSWORD + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-app + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-app + key: password + - name: DB_HOST + value: "postgres-rw.default.svc.cluster.local" + - name: DB_NAME + value: "app" + - name: DB_SSLMODE + value: "require" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 diff --git a/apps/writer/kustomize/ephemeral-base/ingress.yaml b/apps/writer/kustomize/ephemeral-base/ingress.yaml new file mode 100644 index 0000000..b6526aa --- /dev/null +++ b/apps/writer/kustomize/ephemeral-base/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: writer + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + external-dns.alpha.kubernetes.io/hostname: "endpoint" + cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: "traefik" + tls: + - hosts: + - "endpoint" + secretName: writer-tls-ephemeral + rules: + - host: "endpoint" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: writer + port: + number: 8080 diff --git a/apps/writer/kustomize/ephemeral-base/kustomization.yaml b/apps/writer/kustomize/ephemeral-base/kustomization.yaml new file mode 100644 index 0000000..df0f21c --- /dev/null +++ b/apps/writer/kustomize/ephemeral-base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service.yaml + - deployment.yaml + - ingress.yaml + +namespace: default diff --git a/apps/writer/kustomize/ephemeral-base/service.yaml b/apps/writer/kustomize/ephemeral-base/service.yaml new file mode 100644 index 0000000..75b43a5 --- /dev/null +++ b/apps/writer/kustomize/ephemeral-base/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: writer +spec: + selector: + app: writer + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + type: ClusterIP diff --git a/apps/writer/kustomize/overlays/ephemeral/kustomization.yaml b/apps/writer/kustomize/overlays/ephemeral/kustomization.yaml new file mode 100644 index 0000000..bd80254 --- /dev/null +++ b/apps/writer/kustomize/overlays/ephemeral/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: default + +resources: + - ../../ephemeral-base + +images: + - name: registry.fullstack.pw/library/writer + newTag: pr-will-be-replaced + +patches: + - patch: |- + - op: replace + path: /metadata/annotations/external-dns.alpha.kubernetes.io~1hostname + value: "dev.writer.fullstack.pw" + - op: replace + path: /spec/tls/0/hosts/0 + value: "dev.writer.fullstack.pw" + - op: replace + path: /spec/rules/0/host + value: "dev.writer.fullstack.pw" + target: + kind: Ingress + name: writer diff --git a/cypress.config.js b/cypress.config.js index c147164..de8cfe6 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -86,7 +86,7 @@ module.exports = defineConfig({ supportFile: 'cypress/support/e2e.js', defaultCommandTimeout: 10000, requestTimeout: 8000, - responseTimeout: 30000, + responseTimeout: 180000, retries: { runMode: 2, openMode: 0 diff --git a/cypress/e2e/enqueuer.cy.js b/cypress/e2e/enqueuer.cy.js index d3d7fa4..712b10f 100644 --- a/cypress/e2e/enqueuer.cy.js +++ b/cypress/e2e/enqueuer.cy.js @@ -7,6 +7,8 @@ describe('Enqueuer Service Tests', () => { }); it('should accept and queue valid messages', () => { + const queueName = `queue-${Cypress.env('ENVIRONMENT')}`; + // Test with valid message const validMessage = { content: `Linux Tux`, @@ -15,7 +17,7 @@ describe('Enqueuer Service Tests', () => { cy.request({ method: 'POST', - url: `${Cypress.env('ENQUEUER_URL')}/add?queue=queue-dev`, + url: `${Cypress.env('ENQUEUER_URL')}/add?queue=${queueName}`, body: validMessage, headers: { 'Content-Type': 'application/json' @@ -27,7 +29,7 @@ describe('Enqueuer Service Tests', () => { if (response.status === 201) { expect(response.body).to.have.property('status'); - expect(response.body).to.have.property('queue', 'queue-dev'); + expect(response.body).to.have.property('queue', queueName); expect(response.body).to.have.property('image_url').and.not.be.empty; // ASCII fields are present but may be empty if memorizer didn't respond in time expect(response.body).to.have.property('image_ascii_text'); diff --git a/cypress/e2e/full-pipeline.cy.js b/cypress/e2e/full-pipeline.cy.js index 8bb3f5e..034319c 100644 --- a/cypress/e2e/full-pipeline.cy.js +++ b/cypress/e2e/full-pipeline.cy.js @@ -29,6 +29,7 @@ describe('Microservices Pipeline Tests', () => { expect(response.status).to.equal(201); cy.log(`Message ${testId} sent to pipeline`); + // If enqueuer returned 201, memorizer has already processed and stored the message // Check if the message was processed by Memorizer via Enqueuer's check endpoint cy.request({ method: 'GET', diff --git a/cypress/fixtures/environments.json b/cypress/fixtures/environments.json index 63c63af..156ae6e 100644 --- a/cypress/fixtures/environments.json +++ b/cypress/fixtures/environments.json @@ -10,5 +10,8 @@ }, "local": { "enqueuer": "http://localhost:8081" + }, + "ephemeral": { + "enqueuer": "PLACEHOLDER_WILL_BE_OVERRIDDEN" } } \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 8a7fea6..afe298d 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,10 +1,14 @@ // This file allows to create custom Cypress commands and overwrite existing ones // -- This is a parent command -- -Cypress.Commands.add('sendMessage', (message, queueName = 'cypress-test') => { +Cypress.Commands.add('sendMessage', (message, queueName = null) => { + // Use environment-specific queue name if not explicitly provided + const defaultQueue = `queue-${Cypress.env('ENVIRONMENT')}`; + const queue = queueName || defaultQueue; + return cy.request({ method: 'POST', - url: `${Cypress.env('ENQUEUER_URL')}/add?queue=${queueName}`, + url: `${Cypress.env('ENQUEUER_URL')}/add?queue=${queue}`, body: message, headers: { 'Content-Type': 'application/json'