Added proper CI
All checks were successful
OpenClimb Docker Deploy / build-and-push (pull_request) Successful in 2m3s

This commit is contained in:
2025-09-28 23:29:44 -06:00
parent 6e490d1598
commit f7f1fba9aa
11 changed files with 348 additions and 3 deletions

View File

@@ -1,14 +0,0 @@
# OpenClimb Sync Server Configuration
# Required: Secret token for authentication
# Generate a secure random token and share it between your apps and server
AUTH_TOKEN=your-secure-secret-token-here
# Optional: Port to run the server on (default: 8080)
PORT=8080
# Optional: Path to store the sync data (default: ./data/climb_data.json)
DATA_FILE=./data/climb_data.json
# Optional: Directory to store images (default: ./data/images)
IMAGES_DIR=./data/images

View File

@@ -1,16 +0,0 @@
# Binaries
sync-server
openclimb-sync
# Go workspace file
go.work
# Data directory
data/
# Environment files
.env
.env.local
# OS generated files
.DS_Store

303
sync-server/DEPLOY.md Normal file
View File

@@ -0,0 +1,303 @@
# OpenClimb Sync Server Deployment Guide
This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system.
## Overview
The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment.
## Prerequisites
- Docker and Docker Compose installed
- Access to the container registry (configured in GitHub secrets)
- Basic understanding of Docker deployments
## Quick Start
### 1. Automated Deployment (Recommended)
```bash
# Clone the repository
git clone <your-repo-url>
cd OpenClimb/sync-server
# Run the deployment script
./deploy.sh
```
The script will:
- Create necessary directories
- Pull the latest container image
- Stop any existing containers
- Start the new container
- Verify deployment success
### 2. Manual Deployment
```bash
# Pull the latest image
docker pull your-registry.com/username/openclimb-sync-server:latest
# Create environment file
cp .env.example .env.prod
# Edit .env.prod with your configuration
# Deploy with docker-compose
docker-compose -f docker-compose.prod.yml up -d
```
## Configuration
### Environment Variables
Create a `.env.prod` file with the following variables:
```bash
# Container registry settings
REPO_HOST=your-registry.example.com
REPO_OWNER=your-username
# Server configuration
AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random
PORT=8080
# Optional: Custom domain (for Traefik)
TRAEFIK_HOST=sync.openclimb.example.com
```
### Required Secrets (GitHub)
Configure these secrets in your GitHub repository settings:
- `REPO_HOST`: Your container registry hostname
- `DEPLOY_TOKEN`: Authentication token for the registry
## Container Build Process
The GitHub Action (`sync-server-deploy.yml`) automatically:
1. **Triggers on:**
- Push to `main` branch (when sync-server files change)
- Pull requests to `main` branch
2. **Build Process:**
- Uses multi-stage Docker build
- Compiles Go binary in builder stage
- Creates minimal Alpine-based runtime image
- Pushes to container registry with tags:
- `latest` (always points to newest)
- `<commit-sha>` (specific version)
3. **Caching:**
- Uses GitHub Actions cache for faster builds
- Incremental builds when possible
## Deployment Options
### Option 1: Simple Docker Run
```bash
docker run -d \
--name openclimb-sync-server \
-p 8080:8080 \
-v $(pwd)/data:/root/data \
-e AUTH_TOKEN=your-token-here \
your-registry.com/username/openclimb-sync-server:latest
```
### Option 2: Docker Compose (Recommended)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Option 3: Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: openclimb-sync-server
spec:
replicas: 1
selector:
matchLabels:
app: openclimb-sync-server
template:
metadata:
labels:
app: openclimb-sync-server
spec:
containers:
- name: sync-server
image: your-registry.com/username/openclimb-sync-server:latest
ports:
- containerPort: 8080
env:
- name: AUTH_TOKEN
valueFrom:
secretKeyRef:
name: openclimb-secrets
key: auth-token
volumeMounts:
- name: data-volume
mountPath: /root/data
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: openclimb-data
```
## Data Persistence
The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data:
```bash
# Local directory mounting
-v $(pwd)/data:/root/data
# Named volume (recommended for production)
-v openclimb-data:/root/data
```
### Data Structure
```
data/
├── climb_data.json # Main sync data
├── images/ # Uploaded images
│ ├── problem_*.jpg
│ └── ...
└── logs/ # Server logs (optional)
```
## Monitoring and Maintenance
### Health Check
```bash
curl http://localhost:8080/health
```
### View Logs
```bash
# Docker Compose
docker-compose -f docker-compose.prod.yml logs -f
# Direct Docker
docker logs -f openclimb-sync-server
```
### Update to Latest Version
```bash
# Using deploy script
./deploy.sh
# Manual update
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
## Reverse Proxy Setup (Optional)
### Nginx
```nginx
server {
listen 80;
server_name sync.openclimb.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Traefik (Labels included in docker-compose.prod.yml)
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)"
- "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt"
```
## Security Considerations
1. **AUTH_TOKEN**: Use a long, random token (32+ characters)
2. **HTTPS**: Always use HTTPS in production (via reverse proxy)
3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly
4. **Updates**: Regularly update to the latest container image
5. **Backups**: Regularly backup the `data/` directory
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs openclimb-sync-server
# Common issues:
# - Missing AUTH_TOKEN environment variable
# - Port 8080 already in use
# - Insufficient permissions on data directory
```
### Sync Fails from Mobile Apps
```bash
# Verify server is accessible
curl -H "Authorization: Bearer your-token" http://your-server:8080/sync
# Check server logs for authentication errors
docker logs openclimb-sync-server | grep "401\|403"
```
### Image Upload Issues
```bash
# Check disk space
df -h
# Verify data directory permissions
ls -la data/
```
## Performance Tuning
For high-load deployments:
```yaml
# docker-compose.prod.yml
services:
openclimb-sync-server:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
```
## Backup Strategy
```bash
#!/bin/bash
# backup.sh - Run daily via cron
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/openclimb"
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Backup data directory
tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \
-C /path/to/sync-server data/
# Keep only last 30 days
find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete
```
## Support
- **Issues**: Create an issue in the GitHub repository
- **Documentation**: Check the main OpenClimb README
- **Logs**: Always

View File

@@ -1,14 +0,0 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o sync-server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/sync-server .
EXPOSE 8080
CMD ["./sync-server"]

View File

@@ -1,14 +0,0 @@
version: "3.8"
services:
openclimb-sync:
build: .
ports:
- "8080:8080"
environment:
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
- DATA_FILE=/data/climb_data.json
- IMAGES_DIR=/data/images
volumes:
- ./data:/data
restart: unless-stopped

View File

@@ -1,3 +0,0 @@
module openclimb-sync
go 1.25

View File

@@ -1,358 +0,0 @@
package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func min(a, b int) int {
if a < b {
return a
}
return b
}
type ClimbDataBackup struct {
ExportedAt string `json:"exportedAt"`
Version string `json:"version"`
FormatVersion string `json:"formatVersion"`
Gyms []BackupGym `json:"gyms"`
Problems []BackupProblem `json:"problems"`
Sessions []BackupClimbSession `json:"sessions"`
Attempts []BackupAttempt `json:"attempts"`
}
type BackupGym struct {
ID string `json:"id"`
Name string `json:"name"`
Location *string `json:"location,omitempty"`
SupportedClimbTypes []string `json:"supportedClimbTypes"`
DifficultySystems []string `json:"difficultySystems"`
CustomDifficultyGrades []string `json:"customDifficultyGrades"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BackupProblem struct {
ID string `json:"id"`
GymID string `json:"gymId"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
ClimbType string `json:"climbType"`
Difficulty DifficultyGrade `json:"difficulty"`
Tags []string `json:"tags"`
Location *string `json:"location,omitempty"`
ImagePaths []string `json:"imagePaths,omitempty"`
IsActive bool `json:"isActive"`
DateSet *string `json:"dateSet,omitempty"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type DifficultyGrade struct {
System string `json:"system"`
Grade string `json:"grade"`
NumericValue int `json:"numericValue"`
}
type BackupClimbSession struct {
ID string `json:"id"`
GymID string `json:"gymId"`
Date string `json:"date"`
StartTime *string `json:"startTime,omitempty"`
EndTime *string `json:"endTime,omitempty"`
Duration *int64 `json:"duration,omitempty"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BackupAttempt struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
ProblemID string `json:"problemId"`
Result string `json:"result"`
HighestHold *string `json:"highestHold,omitempty"`
Notes *string `json:"notes,omitempty"`
Duration *int64 `json:"duration,omitempty"`
RestTime *int64 `json:"restTime,omitempty"`
Timestamp string `json:"timestamp"`
CreatedAt string `json:"createdAt"`
}
type SyncServer struct {
authToken string
dataFile string
imagesDir string
}
func (s *SyncServer) authenticate(r *http.Request) bool {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
return false
}
token := strings.TrimPrefix(authHeader, "Bearer ")
return subtle.ConstantTimeCompare([]byte(token), []byte(s.authToken)) == 1
}
func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
log.Printf("Loading data from: %s", s.dataFile)
if _, err := os.Stat(s.dataFile); os.IsNotExist(err) {
log.Printf("Data file does not exist, creating empty backup")
return &ClimbDataBackup{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}, nil
}
data, err := os.ReadFile(s.dataFile)
if err != nil {
log.Printf("Failed to read data file: %v", err)
return nil, err
}
log.Printf("Read %d bytes from data file", len(data))
log.Printf("File content preview: %s", string(data[:min(200, len(data))]))
var backup ClimbDataBackup
if err := json.Unmarshal(data, &backup); err != nil {
log.Printf("Failed to unmarshal JSON: %v", err)
return nil, err
}
log.Printf("Loaded backup: gyms=%d, problems=%d, sessions=%d, attempts=%d",
len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts))
return &backup, nil
}
func (s *SyncServer) saveData(backup *ClimbDataBackup) error {
backup.ExportedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.MarshalIndent(backup, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(s.dataFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Ensure images directory exists
if err := os.MkdirAll(s.imagesDir, 0755); err != nil {
return err
}
return os.WriteFile(s.dataFile, data, 0644)
}
func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized access attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
log.Printf("GET /sync request from %s", r.RemoteAddr)
backup, err := s.loadData()
if err != nil {
log.Printf("Failed to load data: %v", err)
http.Error(w, "Failed to load data", http.StatusInternalServerError)
return
}
log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d",
r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backup)
}
func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized sync attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var backup ClimbDataBackup
if err := json.NewDecoder(r.Body).Decode(&backup); err != nil {
log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := s.saveData(&backup); err != nil {
log.Printf("Failed to save data: %v", err)
http.Error(w, "Failed to save data", http.StatusInternalServerError)
return
}
log.Printf("Data synced by %s", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backup)
}
func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *SyncServer) handleImageUpload(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized image upload attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Missing filename parameter", http.StatusBadRequest)
return
}
imageData, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read image data", http.StatusBadRequest)
return
}
imagePath := filepath.Join(s.imagesDir, filename)
if err := os.WriteFile(imagePath, imageData, 0644); err != nil {
log.Printf("Failed to save image %s: %v", filename, err)
http.Error(w, "Failed to save image", http.StatusInternalServerError)
return
}
log.Printf("Image uploaded: %s (%d bytes) by %s", filename, len(imageData), r.RemoteAddr)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "uploaded"})
}
func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized image download attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Missing filename parameter", http.StatusBadRequest)
return
}
imagePath := filepath.Join(s.imagesDir, filename)
imageData, err := os.ReadFile(imagePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Image not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to read image", http.StatusInternalServerError)
}
return
}
// Set appropriate content type based on file extension
ext := filepath.Ext(filename)
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.WriteHeader(http.StatusOK)
w.Write(imageData)
}
func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGet(w, r)
case http.MethodPut:
s.handlePut(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func main() {
authToken := os.Getenv("AUTH_TOKEN")
if authToken == "" {
log.Fatal("AUTH_TOKEN environment variable is required")
}
dataFile := os.Getenv("DATA_FILE")
if dataFile == "" {
dataFile = "./data/climb_data.json"
}
imagesDir := os.Getenv("IMAGES_DIR")
if imagesDir == "" {
imagesDir = "./data/images"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &SyncServer{
authToken: authToken,
dataFile: dataFile,
imagesDir: imagesDir,
}
http.HandleFunc("/sync", server.handleSync)
http.HandleFunc("/health", server.handleHealth)
http.HandleFunc("/images/upload", server.handleImageUpload)
http.HandleFunc("/images/download", server.handleImageDownload)
fmt.Printf("OpenClimb sync server starting on port %s\n", port)
fmt.Printf("Data file: %s\n", dataFile)
fmt.Printf("Images directory: %s\n", imagesDir)
fmt.Printf("Health check available at /health\n")
fmt.Printf("Image upload: POST /images/upload?filename=<name>\n")
fmt.Printf("Image download: GET /images/download?filename=<name>\n")
log.Fatal(http.ListenAndServe(":"+port, nil))
}

View File

@@ -1,31 +0,0 @@
#!/bin/bash
# OpenClimb Sync Server Runner
set -e
# Default values
AUTH_TOKEN=${AUTH_TOKEN:-}
PORT=${PORT:-8080}
DATA_FILE=${DATA_FILE:-./data/climb_data.json}
# Check if AUTH_TOKEN is set
if [ -z "$AUTH_TOKEN" ]; then
echo "Error: AUTH_TOKEN environment variable must be set"
echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh"
echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh"
exit 1
fi
# Create data directory if it doesn't exist
mkdir -p "$(dirname "$DATA_FILE")"
# Build and run
echo "Building OpenClimb sync server..."
go build -o sync-server .
echo "Starting server on port $PORT"
echo "Data will be stored in: $DATA_FILE"
echo "Images will be stored in: ${IMAGES_DIR:-./data/images}"
echo "Use Authorization: Bearer $AUTH_TOKEN in your requests"
echo ""
exec ./sync-server