Skip to content

Commit f913264

Browse files
author
John Rogers
committed
Add restore UI with Docker support and PostgreSQL integration
1 parent 16bd733 commit f913264

File tree

6 files changed

+311
-12
lines changed

6 files changed

+311
-12
lines changed

.github/workflows/docker-build.yml

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ on:
66
pull_request:
77
branches: [ "main" ]
88

9+
permissions:
10+
contents: read
11+
packages: write # Needed to push images to GHCR
12+
913
jobs:
1014
build:
11-
name: Build Docker Image
15+
name: Build and Push Docker Images
1216
runs-on: ubuntu-latest
1317
steps:
1418
- name: Checkout code
@@ -20,18 +24,42 @@ jobs:
2024
- name: Set up Docker Buildx
2125
uses: docker/setup-buildx-action@v3
2226

23-
# Add login step here if you need to push to a registry
24-
# - name: Login to Docker Hub
25-
# uses: docker/login-action@v3
26-
# with:
27-
# username: ${{ secrets.DOCKERHUB_USERNAME }}
28-
# password: ${{ secrets.DOCKERHUB_TOKEN }}
27+
- name: Log in to GitHub Container Registry
28+
uses: docker/login-action@v3
29+
with:
30+
registry: ghcr.io
31+
username: ${{ github.actor }}
32+
password: ${{ secrets.GITHUB_TOKEN }}
33+
34+
- name: Build and push app image
35+
uses: docker/build-push-action@v5
36+
with:
37+
context: ./app
38+
file: ./app/Dockerfile
39+
platforms: linux/amd64,linux/arm64
40+
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
41+
tags: |
42+
ghcr.io/${{ github.repository_owner }}/laterbase-app:${{ github.sha }}
43+
ghcr.io/${{ github.repository_owner }}/laterbase-app:latest
44+
45+
- name: Build and push backup image
46+
uses: docker/build-push-action@v5
47+
with:
48+
context: ./backup
49+
file: ./backup/Dockerfile.backup # Using the specific name
50+
platforms: linux/amd64,linux/arm64
51+
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
52+
tags: |
53+
ghcr.io/${{ github.repository_owner }}/laterbase-backup:${{ github.sha }}
54+
ghcr.io/${{ github.repository_owner }}/laterbase-backup:latest
2955
30-
- name: Build Docker image (multi-platform)
56+
- name: Build and push restore_ui image
3157
uses: docker/build-push-action@v5
3258
with:
33-
context: .
34-
file: ./Dockerfile
59+
context: ./restore_ui
60+
file: ./restore_ui/Dockerfile
3561
platforms: linux/amd64,linux/arm64
36-
push: false # Set to true if you want to push the image
37-
# tags: your-dockerhub-username/your-repo:latest # Uncomment and replace if pushing
62+
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
63+
tags: |
64+
ghcr.io/${{ github.repository_owner }}/laterbase-restore-ui:${{ github.sha }}
65+
ghcr.io/${{ github.repository_owner }}/laterbase-restore-ui:latest

docker-compose.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,34 @@ services:
102102
- network
103103
restart: unless-stopped
104104

105+
# --- Restore UI Service ---
106+
restore-ui:
107+
build:
108+
context: ./restore_ui
109+
dockerfile: Dockerfile
110+
container_name: restore_ui
111+
env_file:
112+
- .env
113+
environment:
114+
# Connection details for the DB server where new DBs will be created
115+
TARGET_DB_HOST: ${PRIMARY_HOST} # Connect to the primary DB service
116+
TARGET_DB_PORT: ${PRIMARY_PORT:-5432} # Use the same port as primary/standby
117+
TARGET_DB_USER: ${POSTGRES_USER:-postgres} # Use the same user as standby
118+
TARGET_DB_PASSWORD: ${REPL_PASSWORD} # Use the standby's password (MUST be in .env)
119+
# Flask specific settings
120+
FLASK_ENV: development # Set to 'production' for production
121+
PYTHONUNBUFFERED: 1 # Ensure logs appear instantly
122+
volumes:
123+
# Mount the shared backup directory (read-only)
124+
- ${LOCAL_BACKUP_PATH:-./backups}:/backups:ro
125+
ports:
126+
- "5001:5001" # Expose Flask app port
127+
networks:
128+
- network
129+
restart: unless-stopped
130+
depends_on:
131+
- standby # Ensure the target DB service is running
132+
105133
# --- Ofelia Scheduler Service ---
106134
scheduler:
107135
image: mcuadros/ofelia:latest

restore_ui/Dockerfile

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Use an official Python runtime as a parent image
2+
FROM python:3.9-slim
3+
4+
# Set the working directory in the container
5+
WORKDIR /app
6+
7+
# Install system dependencies:
8+
# - postgresql-client: Provides psql, createdb, dropdb
9+
# - gzip: Provides gunzip (often included, but good to be explicit)
10+
# Clean up apt cache afterwards to keep image size down
11+
RUN apt-get update && \
12+
apt-get install -y --no-install-recommends postgresql-client gzip && \
13+
rm -rf /var/lib/apt/lists/*
14+
15+
# Copy the requirements file into the container at /app
16+
COPY requirements.txt .
17+
18+
# Install any needed packages specified in requirements.txt
19+
RUN pip install --no-cache-dir -r requirements.txt
20+
21+
# Copy the rest of the application code into the container at /app
22+
COPY . .
23+
24+
# Make port 5001 available to the world outside this container
25+
EXPOSE 5001
26+
27+
# Define environment variables (can be overridden at runtime)
28+
# Default target DB connection details (match app.py defaults)
29+
# TARGET_DB_PASSWORD MUST be provided via docker-compose or .env
30+
31+
# Run app.py when the container launches
32+
CMD ["python", "app.py"]

restore_ui/app.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import os
2+
import subprocess
3+
from flask import Flask, render_template, request, flash, redirect, url_for
4+
5+
app = Flask(__name__)
6+
app.secret_key = os.urandom(24) # Needed for flashing messages
7+
8+
BACKUP_DIR = "/backups"
9+
# --- Database Connection Details (Read from Environment Variables) ---
10+
# These will be needed for the restore operation
11+
TARGET_DB_HOST = os.environ.get("TARGET_DB_HOST", "db") # Default to 'db' service name
12+
TARGET_DB_PORT = os.environ.get("TARGET_DB_PORT", "5432")
13+
TARGET_DB_USER = os.environ.get("TARGET_DB_USER", "postgres")
14+
TARGET_DB_PASSWORD = os.environ.get("TARGET_DB_PASSWORD") # MUST be provided
15+
16+
DEFAULT_RESTORE_SUFFIX = "_restore"
17+
18+
def get_backup_files():
19+
"""Scans the backup directory for .sql.gz files."""
20+
backups = []
21+
if not os.path.isdir(BACKUP_DIR):
22+
flash(f"Error: Backup directory '{BACKUP_DIR}' not found or not accessible.", "danger")
23+
return backups
24+
try:
25+
for filename in sorted(os.listdir(BACKUP_DIR), reverse=True):
26+
if filename.endswith(".sql.gz"):
27+
# Extract original DB name (assuming format DB_NAME_backup_TIMESTAMP.sql.gz)
28+
parts = filename.split('_backup_')
29+
original_db = parts[0] if len(parts) > 0 else "unknown_db"
30+
backups.append({"filename": filename, "original_db": original_db})
31+
except OSError as e:
32+
flash(f"Error reading backup directory '{BACKUP_DIR}': {e}", "danger")
33+
return backups
34+
35+
@app.route('/')
36+
def index():
37+
"""Displays the list of backups and the restore form."""
38+
backup_files = get_backup_files()
39+
return render_template('index.html', backups=backup_files, default_suffix=DEFAULT_RESTORE_SUFFIX)
40+
41+
@app.route('/restore', methods=['POST'])
42+
def restore_backup():
43+
"""Handles the restore request."""
44+
selected_backup = request.form.get('backup_file')
45+
restore_suffix = request.form.get('restore_suffix', DEFAULT_RESTORE_SUFFIX).strip()
46+
47+
if not selected_backup:
48+
flash("Error: No backup file selected.", "danger")
49+
return redirect(url_for('index'))
50+
51+
if not restore_suffix:
52+
restore_suffix = DEFAULT_RESTORE_SUFFIX # Ensure default if user enters empty string
53+
54+
# Basic validation for suffix (prevent potentially harmful characters)
55+
if not restore_suffix.replace('_', '').isalnum():
56+
flash(f"Error: Invalid suffix '{restore_suffix}'. Only letters, numbers, and underscores are allowed.", "danger")
57+
return redirect(url_for('index'))
58+
59+
backup_path = os.path.join(BACKUP_DIR, selected_backup)
60+
61+
if not os.path.exists(backup_path):
62+
flash(f"Error: Selected backup file '{selected_backup}' not found.", "danger")
63+
return redirect(url_for('index'))
64+
65+
# Extract original DB name again for safety
66+
parts = selected_backup.split('_backup_')
67+
original_db_name = parts[0] if len(parts) > 0 else None
68+
69+
if not original_db_name:
70+
flash(f"Error: Could not determine original database name from '{selected_backup}'.", "danger")
71+
return redirect(url_for('index'))
72+
73+
new_db_name = f"{original_db_name}{restore_suffix}"
74+
75+
# --- Input Validation ---
76+
if not TARGET_DB_PASSWORD:
77+
flash("Error: TARGET_DB_PASSWORD environment variable is not set. Cannot connect to target database.", "danger")
78+
return redirect(url_for('index'))
79+
80+
# --- Restore Process ---
81+
try:
82+
# 1. Create the new database
83+
flash(f"Attempting to create new database: '{new_db_name}' on host '{TARGET_DB_HOST}'...", "info")
84+
create_db_cmd = [
85+
"createdb",
86+
"-h", TARGET_DB_HOST,
87+
"-p", TARGET_DB_PORT,
88+
"-U", TARGET_DB_USER,
89+
new_db_name
90+
]
91+
env = os.environ.copy()
92+
env['PGPASSWORD'] = TARGET_DB_PASSWORD
93+
# Use check_output to capture stderr on failure
94+
subprocess.check_output(create_db_cmd, stderr=subprocess.STDOUT, env=env)
95+
flash(f"Successfully created database '{new_db_name}'.", "success")
96+
97+
# 2. Decompress and restore using psql
98+
flash(f"Attempting to restore '{selected_backup}' into '{new_db_name}'...", "info")
99+
# Command: gunzip < backup_path | psql -h host -p port -U user -d new_db_name
100+
# We use shell=True because of the pipe. Be cautious with user input (already validated suffix).
101+
restore_cmd_str = f"gunzip < \"{backup_path}\" | psql -h \"{TARGET_DB_HOST}\" -p \"{TARGET_DB_PORT}\" -U \"{TARGET_DB_USER}\" -d \"{new_db_name}\" --quiet"
102+
103+
# Run the command
104+
# Use check_output to capture stderr on failure
105+
process = subprocess.run(restore_cmd_str, shell=True, capture_output=True, text=True, env=env)
106+
107+
if process.returncode == 0:
108+
flash(f"Successfully restored backup '{selected_backup}' to database '{new_db_name}'.", "success")
109+
else:
110+
# Attempt to drop the partially created/restored DB on failure
111+
flash(f"Error during restore process (Exit Code: {process.returncode}). Attempting cleanup...", "danger")
112+
flash(f"Stderr: {process.stderr}", "warning")
113+
flash(f"Stdout: {process.stdout}", "warning")
114+
try:
115+
drop_db_cmd = [
116+
"dropdb", "--if-exists",
117+
"-h", TARGET_DB_HOST,
118+
"-p", TARGET_DB_PORT,
119+
"-U", TARGET_DB_USER,
120+
new_db_name
121+
]
122+
subprocess.check_output(drop_db_cmd, stderr=subprocess.STDOUT, env=env)
123+
flash(f"Cleaned up (dropped) database '{new_db_name}'.", "info")
124+
except subprocess.CalledProcessError as drop_e:
125+
flash(f"Error during cleanup (dropping '{new_db_name}'): {drop_e.output.decode()}", "danger")
126+
127+
except subprocess.CalledProcessError as e:
128+
flash(f"Error executing command: {e.cmd}", "danger")
129+
flash(f"Output: {e.output.decode()}", "warning")
130+
# If createdb failed, no need to drop
131+
except Exception as e:
132+
flash(f"An unexpected error occurred: {e}", "danger")
133+
134+
return redirect(url_for('index'))
135+
136+
137+
if __name__ == '__main__':
138+
# Use 0.0.0.0 to be accessible within Docker network
139+
app.run(host='0.0.0.0', port=5001, debug=True) # Use a different port like 5001

restore_ui/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Flask>=3.1

restore_ui/templates/index.html

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>PostgreSQL Backup Restore</title>
7+
<!-- Simple styling with Bootstrap CDN -->
8+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
9+
<style>
10+
body { padding: 20px; }
11+
.flash-messages .alert { margin-bottom: 15px; }
12+
.backup-item { display: flex; justify-content: space-between; align-items: center; }
13+
.backup-item span { margin-right: 10px; }
14+
</style>
15+
</head>
16+
<body>
17+
<div class="container">
18+
<h1>PostgreSQL Backup Restore</h1>
19+
<p>Select a backup file and provide a suffix for the new database name.</p>
20+
<p>The new database will be named <code>original_db_name_suffix</code>.</p>
21+
<p>For example, if the original database is <code>mydb</code> and the suffix is <code>_restore_test</code>, the new database will be named <code>mydb_restore_test</code>.</p>
22+
23+
<!-- Flash Messages -->
24+
<div class="flash-messages">
25+
{% with messages = get_flashed_messages(with_categories=true) %}
26+
{% if messages %}
27+
{% for category, message in messages %}
28+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
29+
{{ message }}
30+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
31+
</div>
32+
{% endfor %}
33+
{% endif %}
34+
{% endwith %}
35+
</div>
36+
37+
<hr>
38+
39+
{% if backups %}
40+
<form action="{{ url_for('restore_backup') }}" method="post">
41+
<div class="mb-3">
42+
<label for="backup_file" class="form-label">Select Backup File:</label>
43+
<select class="form-select" id="backup_file" name="backup_file" required>
44+
<option value="" disabled selected>-- Select a backup --</option>
45+
{% for backup in backups %}
46+
<option value="{{ backup.filename }}">
47+
{{ backup.filename }} (Original DB: {{ backup.original_db }})
48+
</option>
49+
{% endfor %}
50+
</select>
51+
</div>
52+
53+
<div class="mb-3">
54+
<label for="restore_suffix" class="form-label">New Database Suffix:</label>
55+
<input type="text" class="form-control" id="restore_suffix" name="restore_suffix" value="{{ default_suffix }}" placeholder="e.g., _restore_test" required>
56+
<div class="form-text">Only letters, numbers, and underscores allowed. Default: {{ default_suffix }}</div>
57+
</div>
58+
59+
<button type="submit" class="btn btn-primary">Restore Backup</button>
60+
</form>
61+
{% else %}
62+
<div class="alert alert-warning" role="alert">
63+
No backup files found in the backup directory ({{ BACKUP_DIR }}). Ensure backups exist and the volume is mounted correctly.
64+
</div>
65+
{% endif %}
66+
67+
</div>
68+
<!-- Bootstrap JS Bundle (needed for alert dismissal) -->
69+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
70+
</body>
71+
</html>

0 commit comments

Comments
 (0)