diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 000000000..dba9fffc1 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# deploy.sh - Complete deployment script for Hetzner with Docker Hub and R2 +# This script: +# 1. Builds and uploads the Docker image to Docker Hub with appropriate tag +# 2. Copies the update script to Hetzner server +# 3. Executes the update script on the Hetzner server + +set -e # Exit immediately if a command exits with a non-zero status + +# Function to print section headers +print_header() { + echo "======================================================" + echo "🚀 $1" + echo "======================================================" +} + +# Load environment variables +if [ -f .env ]; then + echo "Loading configuration from .env file..." + export $(grep -v '^#' .env | xargs) +fi + +# Check command line argument +if [ $# -ne 1 ] || ([ "$1" != "staging" ] && [ "$1" != "prod" ]); then + echo "Error: Please specify environment (staging or prod)" + echo "Usage: $0 [staging|prod]" + exit 1 +fi + +# TODO: fix this - need to build before creating the image +bun run build-prod + +ENV=$1 +VERSION_TAG="latest" +DOCKER_REPO="" + +# Set environment-specific variables +if [ "$ENV" == "staging" ]; then + print_header "DEPLOYING TO STAGING ENVIRONMENT" + SERVER_HOST=$SERVER_HOST_STAGING + DOCKER_REPO=$DOCKER_REPO_STAGING +else + print_header "DEPLOYING TO PRODUCTION ENVIRONMENT" + SERVER_HOST=$SERVER_HOST_PROD + DOCKER_REPO=$DOCKER_REPO_PROD +fi + +# Check required environment variables +if [ -z "$SERVER_HOST" ]; then + echo "Error: SERVER_HOST_${ENV^^} not defined in .env file or environment" + exit 1 +fi + +# Configuration +SSH_KEY=${SSH_KEY:-"~/.ssh/id_rsa"} # Use default or override from .env +DOCKER_USERNAME=${DOCKER_USERNAME} # Docker Hub username +UPDATE_SCRIPT="./update.sh" # Path to your update script +REMOTE_UPDATE_SCRIPT="/root/update-openfront.sh" # Where to place the script on server + +# Check if update script exists +if [ ! -f "$UPDATE_SCRIPT" ]; then + echo "Error: Update script $UPDATE_SCRIPT not found!" + exit 1 +fi + +# Step 1: Build and upload Docker image to Docker Hub +print_header "STEP 1: Building and uploading Docker image to Docker Hub" +echo "Environment: ${ENV}" +echo "Using version tag: $VERSION_TAG" +echo "Docker repository: $DOCKER_REPO" + +# Get Git commit for build info +GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") +echo "Git commit: $GIT_COMMIT" + +docker buildx build \ + --no-cache \ + --platform linux/amd64 \ + --build-arg GIT_COMMIT=$GIT_COMMIT \ + -t $DOCKER_USERNAME/$DOCKER_REPO:$VERSION_TAG \ + --push \ + . + +if [ $? -ne 0 ]; then + echo "❌ Docker build failed. Stopping deployment." + exit 1 +fi + +if [ $? -ne 0 ]; then + echo "❌ Failed to push image to Docker Hub. Stopping deployment." + exit 1 +fi + +echo "✅ Docker image built and pushed successfully." + +# Step 2: Copy update script to Hetzner server +print_header "STEP 2: Copying update script to server" +echo "Target: $SERVER_HOST" + +# Make sure the update script is executable +chmod +x $UPDATE_SCRIPT + +# Copy the update script to the server +scp -i $SSH_KEY $UPDATE_SCRIPT $SERVER_HOST:$REMOTE_UPDATE_SCRIPT + +# Copy environment variables if needed +if [ -f .env ]; then + scp -i $SSH_KEY .env $SERVER_HOST:/root/.env + # Secure the .env file + ssh -i $SSH_KEY $SERVER_HOST "chmod 600 /root/.env" +fi + +if [ $? -ne 0 ]; then + echo "❌ Failed to copy update script to server. Stopping deployment." + exit 1 +fi + +echo "✅ Update script successfully copied to server." + +# Step 3: Execute the update script on the server +print_header "STEP 3: Executing update script on server" + +# Make the script executable on the remote server and execute it with the environment parameter +ssh -i $SSH_KEY $SERVER_HOST "chmod +x $REMOTE_UPDATE_SCRIPT && $REMOTE_UPDATE_SCRIPT $ENV $DOCKER_USERNAME $DOCKER_REPO" + +if [ $? -ne 0 ]; then + echo "❌ Failed to execute update script on server." + exit 1 +fi + +print_header "DEPLOYMENT COMPLETED SUCCESSFULLY" +echo "✅ New version deployed to ${ENV} environment!" +echo "🌐 Check your ${ENV} server to verify the deployment." +echo "=======================================================" \ No newline at end of file diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 28b9a224e..b4ef2161d 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -102,6 +102,10 @@ export interface ServerConfig { adminHeader(): string; // Only available on the server gitCommit(): string; + r2Bucket(): string; + r2Endpoint(): string; + r2AccessKey(): string; + r2SecretKey(): string; } export interface Config { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 0adc11bb0..88c5c214b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -25,6 +25,16 @@ export abstract class DefaultServerConfig implements ServerConfig { gitCommit(): string { return process.env.GIT_COMMIT; } + r2Endpoint(): string { + return process.env.R2_ENDPOINT; + } + r2AccessKey(): string { + return process.env.R2_ACCESS_KEY; + } + r2SecretKey(): string { + return process.env.R2_SECRET_KEY; + } + abstract r2Bucket(): string; adminHeader(): string { return "x-admin-key"; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 619916787..964f7ad56 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -5,6 +5,9 @@ import { GameEnv, ServerConfig } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; export class DevServerConfig extends DefaultServerConfig { + r2Bucket(): string { + return "openfront-staging"; + } adminToken(): string { return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION"; } diff --git a/src/core/configuration/PreprodConfig.ts b/src/core/configuration/PreprodConfig.ts index 3b1aa061f..9340053d4 100644 --- a/src/core/configuration/PreprodConfig.ts +++ b/src/core/configuration/PreprodConfig.ts @@ -2,6 +2,9 @@ import { GameEnv } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; export const preprodConfig = new (class extends DefaultServerConfig { + r2Bucket(): string { + return "openfront-staging"; + } env(): GameEnv { return GameEnv.Preprod; } diff --git a/src/core/configuration/ProdConfig.ts b/src/core/configuration/ProdConfig.ts index 71b9bd122..8d6860358 100644 --- a/src/core/configuration/ProdConfig.ts +++ b/src/core/configuration/ProdConfig.ts @@ -2,6 +2,9 @@ import { GameEnv } from "./Config"; import { DefaultConfig, DefaultServerConfig } from "./DefaultConfig"; export const prodConfig = new (class extends DefaultServerConfig { + r2Bucket(): string { + return "openfront-prod"; + } numWorkers(): number { return 6; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 2600e700d..9b6117542 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,6 +1,5 @@ import { GameRecord, GameID, GameRecordSchema } from "../core/Schemas"; import { S3 } from "@aws-sdk/client-s3"; -import { RedshiftData } from "@aws-sdk/client-redshift-data"; import { GameEnv, getServerConfigFromServer, @@ -8,23 +7,32 @@ import { const config = getServerConfigFromServer(); -const s3 = new S3({ region: "eu-west-1" }); +// R2 client configuration +const r2 = new S3({ + region: "auto", // R2 ignores region, but it's required by the SDK + endpoint: config.r2Endpoint(), // You'll need to add this to your config + credentials: { + accessKeyId: config.r2AccessKey(), // You'll need to add these + secretAccessKey: config.r2SecretKey(), // credential methods to your config + }, +}); -const gameBucket = "openfront-games"; -const analyticsBucket = "openfront-analytics"; +const bucket = config.r2Bucket(); +const gameFolder = "games"; +const analyticsFolder = "analytics"; export async function archive(gameRecord: GameRecord) { try { gameRecord.gitCommit = config.gitCommit(); - // Archive to Redshift Serverless - await archiveAnalyticsToS3(gameRecord); + // Archive to R2 + await archiveAnalyticsToR2(gameRecord); - // Archive to S3 if there are turns + // Archive full game if there are turns if (gameRecord.turns.length > 0) { console.log( - `${gameRecord.id}: game has more than zero turns, attempting to write to full game to S3`, + `${gameRecord.id}: game has more than zero turns, attempting to write to full game to R2`, ); - await archiveFullGameToS3(gameRecord); + await archiveFullGameToR2(gameRecord); } } catch (error) { console.error(`${gameRecord.id}: Final archive error: ${error}`, { @@ -36,8 +44,8 @@ export async function archive(gameRecord: GameRecord) { } } -async function archiveAnalyticsToS3(gameRecord: GameRecord) { - // Create analytics data object (similar to what was going to Redshift) +async function archiveAnalyticsToR2(gameRecord: GameRecord) { + // Create analytics data object const analyticsData = { id: gameRecord.id, env: config.env(), @@ -61,17 +69,17 @@ async function archiveAnalyticsToS3(gameRecord: GameRecord) { // Store analytics data using just the game ID as the key const analyticsKey = `${gameRecord.id}.json`; - await s3.putObject({ - Bucket: analyticsBucket, - Key: analyticsKey, + await r2.putObject({ + Bucket: bucket, + Key: `${analyticsFolder}/${analyticsKey}`, Body: JSON.stringify(analyticsData), ContentType: "application/json", }); - console.log(`${gameRecord.id}: successfully wrote game analytics to S3`); + console.log(`${gameRecord.id}: successfully wrote game analytics to R2`); } catch (error) { console.error( - `${gameRecord.id}: Error writing game analytics to S3: ${error}`, + `${gameRecord.id}: Error writing game analytics to R2: ${error}`, { message: error?.message || error, stack: error?.stack, @@ -83,7 +91,7 @@ async function archiveAnalyticsToS3(gameRecord: GameRecord) { } } -async function archiveFullGameToS3(gameRecord: GameRecord) { +async function archiveFullGameToR2(gameRecord: GameRecord) { // Create a deep copy to avoid modifying the original const recordCopy = JSON.parse(JSON.stringify(gameRecord)); @@ -94,9 +102,9 @@ async function archiveFullGameToS3(gameRecord: GameRecord) { }); try { - await s3.putObject({ - Bucket: gameBucket, - Key: recordCopy.id, + await r2.putObject({ + Bucket: bucket, + Key: `${gameFolder}/${recordCopy.id}`, Body: JSON.stringify(recordCopy), ContentType: "application/json", }); @@ -105,7 +113,7 @@ async function archiveFullGameToS3(gameRecord: GameRecord) { throw error; } - console.log(`${gameRecord.id}: game record successfully written to S3`); + console.log(`${gameRecord.id}: game record successfully written to R2`); } export async function readGameRecord( @@ -113,16 +121,16 @@ export async function readGameRecord( ): Promise { try { // Check if file exists and download in one operation - const response = await s3.getObject({ - Bucket: gameBucket, - Key: gameId, + const response = await r2.getObject({ + Bucket: bucket, + Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder }); // Parse the response body const bodyContents = await response.Body.transformToString(); return JSON.parse(bodyContents) as GameRecord; } catch (error) { // Log the error for monitoring purposes - console.error(`${gameId}: Error reading game record from S3: ${error}`, { + console.error(`${gameId}: Error reading game record from R2: ${error}`, { message: error?.message || error, stack: error?.stack, name: error?.name, @@ -136,9 +144,9 @@ export async function readGameRecord( export async function gameRecordExists(gameId: GameID): Promise { try { - await s3.headObject({ - Bucket: gameBucket, - Key: gameId, + await r2.headObject({ + Bucket: bucket, + Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder }); return true; } catch (error) { diff --git a/update.sh b/update.sh index 05ee3f73e..cfd51a5bb 100755 --- a/update.sh +++ b/update.sh @@ -11,18 +11,23 @@ fi # Set environment from parameter ENV=$1 CONTAINER_NAME="openfront-${ENV}" -LOG_GROUP="/aws/ec2/docker-containers/${ENV}" +IMAGE_NAME="${DOCKER_USERNAME}/${DOCKER_REPO}" +FULL_IMAGE_NAME="${IMAGE_NAME}:latest" -# Get AWS account ID -AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) -ECR_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.eu-west-1.amazonaws.com/openfront:latest" +echo "======================================================" +echo "🔄 UPDATING SERVER: ${ENV} ENVIRONMENT" +echo "======================================================" +echo "Container name: ${CONTAINER_NAME}" +echo "Docker image: ${FULL_IMAGE_NAME}" -echo "Deploying to ${ENV} environment..." -echo "Logging in to ECR..." -aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.eu-west-1.amazonaws.com +# Load environment variables if .env exists +if [ -f /root/.env ]; then + echo "Loading environment variables from .env file..." + export $(grep -v '^#' /root/.env | xargs) +fi -echo "Pulling latest image..." -docker pull $ECR_REPO +echo "Pulling latest image from Docker Hub..." +docker pull $FULL_IMAGE_NAME echo "Checking for existing container..." # Check for running container @@ -64,14 +69,12 @@ fi echo "Starting new container for ${ENV} environment..." docker run -d -p 80:80 \ --restart=always \ - --log-driver=awslogs \ - --log-opt awslogs-region=eu-west-1 \ - --log-opt awslogs-group=${LOG_GROUP} \ - --log-opt awslogs-create-group=true \ + $VOLUME_MOUNTS \ + $NETWORK_FLAGS \ --env GAME_ENV=${ENV} \ - --env-file /home/ec2-user/.env \ + --env-file /root/.env \ --name ${CONTAINER_NAME} \ - $ECR_REPO + $FULL_IMAGE_NAME if [ $? -eq 0 ]; then echo "Update complete! New ${ENV} container is running." @@ -83,4 +86,11 @@ if [ $? -eq 0 ]; then echo "Cleanup complete." else echo "Failed to start container" -fi \ No newline at end of file + exit 1 +fi + +echo "======================================================" +echo "✅ SERVER UPDATE COMPLETED SUCCESSFULLY" +echo "Container name: ${CONTAINER_NAME}" +echo "Image: ${FULL_IMAGE_NAME}" +echo "======================================================" \ No newline at end of file