update to hetzner deployment (#220)

This commit is contained in:
evanpelle
2025-03-12 09:17:27 -07:00
committed by GitHub
parent 3ce5785d1e
commit b1035a8e77
8 changed files with 219 additions and 44 deletions
+134
View File
@@ -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 "======================================================="
+4
View File
@@ -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 {
+10
View File
@@ -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";
}
+3
View File
@@ -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";
}
+3
View File
@@ -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;
}
+3
View File
@@ -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;
}
+36 -28
View File
@@ -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<GameRecord | null> {
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<boolean> {
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) {
+26 -16
View File
@@ -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
exit 1
fi
echo "======================================================"
echo "✅ SERVER UPDATE COMPLETED SUCCESSFULLY"
echo "Container name: ${CONTAINER_NAME}"
echo "Image: ${FULL_IMAGE_NAME}"
echo "======================================================"