diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml new file mode 100644 index 0000000..d00ba20 --- /dev/null +++ b/.github/workflows/aws_auto_release.yml @@ -0,0 +1,239 @@ +name: Auto Release on Main Merge +on: + pull_request: + types: [closed] + branches: + - main + +concurrency: + group: ${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: read + +jobs: + auto_release: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: main + token: ${{ secrets.PAT }} + + - name: Check if user is authorized + id: auth_check + run: | + merged_by="${{ github.event.pull_request.merged_by.login }}" + echo "PR was merged by: $merged_by" + + # Get authorized users from CODEOWNERS file + authorized_users=() + + # Read CODEOWNERS file if it exists + if [[ -f ".github/CODEOWNERS" ]]; then + echo "📋 Reading CODEOWNERS file..." + # Extract usernames from CODEOWNERS (remove @ prefix) + codeowners=$(grep -v '^#' .github/CODEOWNERS | grep -o '@[a-zA-Z0-9_-]*' | sed 's/@//' | sort -u) + for user in $codeowners; do + authorized_users+=("$user") + echo " - CODEOWNER: $user" + done + else + echo "⚠️ No CODEOWNERS file found" + fi + + # Get repository collaborators with admin/maintain permissions using GitHub API + echo "🔍 Checking repository permissions..." + + # Check if user has admin or maintain permissions + user_permission=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/collaborators/$merged_by/permission" | \ + jq -r '.permission // "none"') + + echo "User $merged_by has permission level: $user_permission" + + # Check if user is authorized + is_authorized=false + + # Check if user is in CODEOWNERS + for user in "${authorized_users[@]}"; do + if [[ "$user" == "$merged_by" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized via CODEOWNERS" + break + fi + done + + # Check if user has admin or maintain permissions + if [[ "$user_permission" == "admin" || "$user_permission" == "maintain" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized via repository permissions ($user_permission)" + fi + + # Check if user is organization owner (for metaversecloud-com org) + org_response=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/members/$merged_by" \ + -w "%{http_code}") + + # Extract HTTP status code from the response + http_code=${org_response: -3} + + if [[ "$http_code" == "200" ]]; then + # Check if user is an owner + owner_status=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/memberships/$merged_by" | \ + jq -r '.role // "none"') + + if [[ "$owner_status" == "admin" ]]; then + is_authorized=true + echo "✅ User $merged_by is authorized as organization owner" + fi + fi + + echo "is_authorized=$is_authorized" >> $GITHUB_OUTPUT + + if [[ "$is_authorized" == "false" ]]; then + echo "❌ User $merged_by is not authorized to trigger releases" + echo "💡 Authorized users include:" + echo " - CODEOWNERS: ${authorized_users[*]}" + echo " - Repository admins and maintainers" + echo " - Organization owners" + exit 0 + else + echo "🎉 User $merged_by is authorized to trigger releases" + fi + + - name: Check for release labels and determine version bumps + if: steps.auth_check.outputs.is_authorized == 'true' + id: check + run: | + labels='${{ toJson(github.event.pull_request.labels.*.name) }}' + echo "PR Labels: $labels" + + has_release_label=false + has_major=false + has_minor=false + has_patch=false + + # Check if release label exists + if echo "$labels" | grep -q "release"; then + has_release_label=true + + # Check for each type of version bump + if echo "$labels" | grep -q "major"; then + has_major=true + fi + if echo "$labels" | grep -q "minor"; then + has_minor=true + fi + if echo "$labels" | grep -q "patch"; then + has_patch=true + fi + + # If no specific version type is specified, default to patch + if [[ "$has_major" == "false" && "$has_minor" == "false" && "$has_patch" == "false" ]]; then + has_patch=true + fi + fi + + echo "should_release=$has_release_label" >> $GITHUB_OUTPUT + echo "has_major=$has_major" >> $GITHUB_OUTPUT + echo "has_minor=$has_minor" >> $GITHUB_OUTPUT + echo "has_patch=$has_patch" >> $GITHUB_OUTPUT + echo "Should release: $has_release_label" + echo "Has major: $has_major, minor: $has_minor, patch: $has_patch" + + - name: Setup Node.js + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20.10 + + - name: Calculate new version with cumulative bumps + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + id: version + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Get the latest tag from git + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest git tag: $latest_tag" + + # Remove 'v' prefix if present + current_version=${latest_tag#v} + echo "Current version: $current_version" + + # Parse current version + IFS='.' read -r major minor patch <<< "$current_version" + echo "Parsed version - Major: $major, Minor: $minor, Patch: $patch" + + # Apply cumulative version bumps + if [[ "${{ steps.check.outputs.has_major }}" == "true" ]]; then + major=$((major + 1)) + minor=0 # Reset minor when major is bumped + patch=0 # Reset patch when major is bumped + echo "Applied major bump: $major.0.0" + fi + + if [[ "${{ steps.check.outputs.has_minor }}" == "true" ]]; then + minor=$((minor + 1)) + if [[ "${{ steps.check.outputs.has_major }}" != "true" ]]; then + patch=0 # Reset patch when minor is bumped (only if major wasn't bumped) + fi + echo "Applied minor bump: $major.$minor.$patch" + fi + + if [[ "${{ steps.check.outputs.has_patch }}" == "true" ]]; then + patch=$((patch + 1)) + echo "Applied patch bump: $major.$minor.$patch" + fi + + new_version="$major.$minor.$patch" + echo "Final calculated version: $new_version" + + # Create package.json if it doesn't exist + if [[ ! -f "package.json" ]]; then + echo '{"version": "0.0.0"}' > package.json + fi + + # Update package.json with new version + npm version $new_version --no-git-tag-version --allow-same-version + + echo "NEW_VERSION=v$new_version" >> $GITHUB_ENV + echo "New version will be: v$new_version" + + - name: Create Release + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.PAT }} # Use PAT to trigger other workflows + tag_name: ${{ env.NEW_VERSION }} + name: "Release ${{ env.NEW_VERSION }}" + generate_release_notes: true + make_latest: true + body: | + ## 🚀 Release ${{ env.NEW_VERSION }} + + **Version Bumps Applied:** + - Major: ${{ steps.check.outputs.has_major }} + - Minor: ${{ steps.check.outputs.has_minor }} + - Patch: ${{ steps.check.outputs.has_patch }} + + **Triggered by:** PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }} + **Merged by:** @${{ github.event.pull_request.merged_by.login }} + + ### Changes in this PR + ${{ github.event.pull_request.body }} + + --- + *This release was automatically created by the Auto Release workflow* + diff --git a/.github/workflows/aws_dev_release.yml b/.github/workflows/aws_dev_release.yml index 9674563..46ddb74 100644 --- a/.github/workflows/aws_dev_release.yml +++ b/.github/workflows/aws_dev_release.yml @@ -62,8 +62,6 @@ jobs: - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - with: - mask-password: 'false' - name: Image Metadata id: metadata diff --git a/.github/workflows/aws_prod_release.yml b/.github/workflows/aws_prod_release.yml index 4b375da..3550e1a 100644 --- a/.github/workflows/aws_prod_release.yml +++ b/.github/workflows/aws_prod_release.yml @@ -50,7 +50,7 @@ jobs: cache: 'npm' - run: git config --global user.email devops@topia.io - run: git config --global user.name Devops - - run: npm version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} + - run: npm version --no-git-tag-version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} - run: npm i - run: CI=false npm run build @@ -66,8 +66,6 @@ jobs: - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - with: - mask-password: 'false' - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/server/redis-sse/index.ts b/server/redis-sse/index.ts index aef7dc3..162d981 100644 --- a/server/redis-sse/index.ts +++ b/server/redis-sse/index.ts @@ -1,4 +1,141 @@ -import { createClient } from "redis"; + +import * as redis from "redis"; +import { Response } from "express"; +import { getCredentials } from "../utils/index.js"; + +const RAPID_RETRY_MAX = 10; +const RAPID_ERROR_THRESHOLD = 5000; + +let pubRapidErrorCount = 0; +let pubReconnectionAttempt = 0; +let pubLastReconnectAttemptTime = null; +let pubLastConnectionTime = null; + +let subRapidErrorCount = 0; +let subReconnectionAttempt = 0; +let subLastReconnectAttemptTime = null; +let subLastConnectionTime = null; + +const getRedisHealth = (name) => { + const currentTime = new Date().getTime(); + const lastConnectionTime = name === "pub" ? pubLastConnectionTime : subLastConnectionTime; + const lastReconnectAttemptTime = name === "pub" ? pubLastReconnectAttemptTime : subLastReconnectAttemptTime; + const rapidReconnectCount = name === "pub" ? pubRapidErrorCount : subRapidErrorCount; + const reconnectCount = name === "pub" ? pubReconnectionAttempt : subReconnectionAttempt; + const status = rapidReconnectCount < RAPID_RETRY_MAX ? "OK" : "UNHEALTHY"; + const timeSinceLastReconnectAttempt = lastReconnectAttemptTime ? currentTime - lastReconnectAttemptTime : null; + + return { + status, + currentTime, + lastConnectionTime, + rapidReconnectCount, + reconnectCount, + timeSinceLastReconnectAttempt, + }; +}; + +const handleRedisConnection = (client, name) => { + const { reconnectCount, currentTime, status } = getRedisHealth(name); + const info = reconnectCount ? `status: ${status}, reconnectCount: ${reconnectCount}` : `status: ${status}`; + console.log(`Redis connected - ${name} server, on process: ${process.pid}`, info); + if (name === "pub") pubLastConnectionTime = currentTime; + if (name === "sub") subLastConnectionTime = currentTime; + client.health = getRedisHealth(name); +}; + +const handleRedisReconnection = (name) => { + const { currentTime, timeSinceLastReconnectAttempt } = getRedisHealth(name); + if (name === "pub") { + pubLastReconnectAttemptTime = currentTime; + pubReconnectionAttempt++; + if (timeSinceLastReconnectAttempt && timeSinceLastReconnectAttempt < RAPID_ERROR_THRESHOLD) { + pubRapidErrorCount++; + } + } + if (name === "sub") { + subLastReconnectAttemptTime = currentTime; + subReconnectionAttempt++; + if (timeSinceLastReconnectAttempt && timeSinceLastReconnectAttempt < RAPID_ERROR_THRESHOLD) { + subRapidErrorCount++; + } + } +}; + +const handleRedisError = (name, error) => { + const { reconnectCount, rapidReconnectCount, status, timeSinceLastReconnectAttempt } = getRedisHealth(name); + const info = reconnectCount + ? `status: ${status}, reconnectCount: ${reconnectCount}, rapidReconnectCount: ${rapidReconnectCount} timeSinceLastReconnectAttempt: ${timeSinceLastReconnectAttempt}` + : `status: ${status}`; + console.error(`Redis error - ${name} server, on process: ${process.pid}, ${info}`); + console.error(`Redis error details - ${error}`); +}; + +function getRedisClient(url = process.env.REDIS_URL) { + let isClusterMode = false; + if (typeof process.env.REDIS_CLUSTER_MODE === "undefined") { + console.log("[Redis] Environment variable REDIS_CLUSTER_MODE is not set. Defaulting to false."); + } else { + isClusterMode = process.env.REDIS_CLUSTER_MODE === "true"; + } + const safeUrl = url || ""; + const parsedUrl = new URL(safeUrl); + const host = parsedUrl.hostname; + const port = parsedUrl.port ? parseInt(parsedUrl.port) : 6379; + const username = parsedUrl.username || "default"; + const password = parsedUrl.password || ""; + const tls = safeUrl.startsWith("rediss"); + + if (!isClusterMode) { + return redis.createClient({ + socket: { + host, + port, + tls, + }, + username, + password, + url: safeUrl, + }); + } + return redis.createCluster({ + useReplicas: true, + rootNodes: [ + { + url: safeUrl, + socket: { + tls, + }, + }, + ], + defaults: { + username, + password, + }, + }); +} + +export const redisClient = getRedisClient(); +redisClient.on("connect", () => { + handleRedisConnection(redisClient, "pub"); +}); +redisClient.on("reconnecting", () => { + handleRedisReconnection("pub"); +}); +redisClient.on("error", (error) => { + handleRedisError("pub", error); +}); + +export const redisSubClient = getRedisClient(); +redisSubClient.on("connect", () => { + handleRedisConnection(redisSubClient, "sub"); +}); +redisSubClient.on("reconnecting", () => { + handleRedisReconnection("sub"); +}); +redisSubClient.on("error", (error) => { + handleRedisError("sub", error); +}); const shouldSendEvent = ( data: { assetId: string; visitorId: string | undefined; interactiveNonce: string | undefined }, @@ -6,38 +143,27 @@ const shouldSendEvent = ( visitorId: string, interactiveNonce: string, ) => { - return ( - data.assetId === assetId && - (data.visitorId === undefined || data.visitorId !== visitorId) && - (data.interactiveNonce === undefined || data.interactiveNonce !== interactiveNonce) - ); -}; - -const connectionOpt = { - url: process.env.REDIS_URL, - socket: { - tls: process.env.REDIS_URL!.startsWith("rediss"), - }, + return data.assetId === assetId && data.visitorId !== visitorId && data.interactiveNonce !== interactiveNonce; }; const redisObj = { - publisher: createClient(connectionOpt), - subscriber: createClient(connectionOpt), - publish: function (channel: string, message: any) { - console.log(`Publishing ${message.event} to ${channel}`); + publisher: redisClient, + subscriber: redisSubClient, + connections: [], + publish: function (channel, message) { + if (process.env.NODE_ENV === "development") console.log(`Publishing ${message.event} to ${channel}`); this.publisher.publish(channel, JSON.stringify(message)); }, subscribe: function (channel: string) { this.subscriber.subscribe(channel, (message) => { const data = JSON.parse(message); - console.log(`Event '${data.event}' received on ${channel}`); + if (process.env.NODE_ENV === "development") console.log(`Event '${data.event}' received on ${channel}`); let dataToSend: { data?: any; kind?: string } = {}; if (data.event === "nowPlaying") { dataToSend = { data: { videoId: data.videoId, nextUpId: data.nextUpId }, kind: "nowPlaying" }; } else if (data.event === "mediaAction") { dataToSend = { data: { media: data.videos }, kind: data.kind }; } - this.connections.forEach(({ res: existingConnection }) => { const { assetId, visitorId, interactiveNonce } = existingConnection.req.query; if (shouldSendEvent(data, assetId, visitorId, interactiveNonce)) { @@ -46,10 +172,8 @@ const redisObj = { }); }); }, - connections: [], addConn: function (connection) { const { visitorId, interactiveNonce } = connection.res.req.query; - if ( this.connections.some( ({ res: existingConnection }) => @@ -70,13 +194,15 @@ const redisObj = { } else { this.connections.push(connection); } - console.log(`Connection ${interactiveNonce} added. Length is ${this.connections.length}`); + if (process.env.NODE_ENV === "development") { + console.log(`Connection ${interactiveNonce} added. Length is ${this.connections.length}`); + } }, deleteConn: function () { - // Remove inactive connections older than 30 minutes + // Remove inactive connections older than 15 minutes this.connections = this.connections.filter(({ res, lastHeartbeatTime }) => { const isActive = lastHeartbeatTime > Date.now() - 15 * 60 * 1000; - if (!isActive) { + if (!isActive && process.env.NODE_ENV === "development") { console.log(`Connection to ${res.req.query.interactiveNonce} deleted`); } return isActive;