archetecture security fix
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
## What gets backed up
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `postgres.dump` | Full PostgreSQL database (pg_dump custom format) |
|
||||
| `api-uploads.tar.gz` | User-uploaded files (vehicle images, documents, etc.) |
|
||||
| `traefik-letsencrypt.tar.gz` | Let's Encrypt SSL certificates |
|
||||
| `volumes/` | Raw Docker volume archives (Redis, pgmanage, etc.) |
|
||||
| `manifest.txt` | Backup metadata (timestamp, project name) |
|
||||
|
||||
> **Note:** The `.env.docker.production` file is NOT included in backups. Store it separately in a secure location (password manager, encrypted storage). You will need it to restore on a new server.
|
||||
|
||||
---
|
||||
|
||||
## Running a Backup
|
||||
|
||||
```bash
|
||||
cd ~/car_management_system
|
||||
|
||||
# Default — saves to ./backups/<timestamp>/
|
||||
bash scripts/docker-prod-backup.sh
|
||||
|
||||
# Custom backup directory
|
||||
bash scripts/docker-prod-backup.sh /mnt/external/backups
|
||||
```
|
||||
|
||||
The script will create a timestamped directory, e.g.:
|
||||
```
|
||||
backups/rentaldrivego-prod-20260522T103000Z/
|
||||
```
|
||||
|
||||
PostgreSQL does not need to be stopped. The script brings it up automatically if needed.
|
||||
|
||||
---
|
||||
|
||||
## Running a Restore
|
||||
|
||||
> **Warning:** Restore is destructive. It will overwrite the current database and uploaded files.
|
||||
|
||||
```bash
|
||||
cd ~/car_management_system
|
||||
|
||||
bash scripts/docker-prod-restore.sh backups/rentaldrivego-prod-<timestamp> --yes
|
||||
```
|
||||
|
||||
The `--yes` flag is required. Without it, the script exits with instructions.
|
||||
|
||||
### What the restore does (in order)
|
||||
|
||||
1. Stops all app services (api, dashboard, marketplace, admin, pgmanage, redis, traefik)
|
||||
2. Starts PostgreSQL
|
||||
3. Restores the database with `pg_restore --clean --if-exists`
|
||||
4. Restores uploaded files into the api_uploads volume
|
||||
5. Restores Traefik SSL certificates
|
||||
6. Restores remaining Docker volumes (Redis, pgmanage)
|
||||
7. Starts all services back up
|
||||
|
||||
---
|
||||
|
||||
## Automated Daily Backups (cron)
|
||||
|
||||
To schedule a daily backup at 3:00 AM:
|
||||
|
||||
```bash
|
||||
(crontab -l 2>/dev/null; echo '0 3 * * * cd /root/car_management_system && bash scripts/docker-prod-backup.sh /root/car_management_system/backups >> /var/log/rentaldrivego-backup.log 2>&1') | crontab -
|
||||
```
|
||||
|
||||
Verify the cron job was added:
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
### Cleaning up old backups
|
||||
|
||||
To keep only the last 7 days of backups, add a cleanup job:
|
||||
|
||||
```bash
|
||||
(crontab -l 2>/dev/null; echo '30 3 * * * find /root/car_management_system/backups -maxdepth 1 -name "rentaldrivego-prod-*" -mtime +7 -exec rm -rf {} +') | crontab -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restoring on a Fresh Server
|
||||
|
||||
1. Install Docker and Docker Compose on the new server
|
||||
2. Clone the repository
|
||||
3. Recreate `.env.docker.production` (from your secure backup of that file)
|
||||
4. Copy the backup directory to the new server
|
||||
5. Run the restore script:
|
||||
```bash
|
||||
bash scripts/docker-prod-restore.sh /path/to/backup --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying a Backup
|
||||
|
||||
Check the backup directory contents and sizes:
|
||||
|
||||
```bash
|
||||
ls -lh backups/rentaldrivego-prod-<timestamp>/
|
||||
cat backups/rentaldrivego-prod-<timestamp>/manifest.txt
|
||||
```
|
||||
|
||||
Test that the database dump is valid:
|
||||
|
||||
```bash
|
||||
pg_restore --list backups/rentaldrivego-prod-<timestamp>/postgres.dump | head -20
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
root@rentaldrivego:~# command -v docker && (crontab -l 2>/dev/null | grep -v 'docker image prune -a -f'; echo '0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1') | crontab - && crontab -l | tail -n 5
|
||||
/usr/bin/docker
|
||||
0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1
|
||||
|
||||
#how to run scripts
|
||||
bash scripts/docker-prod-up-frontends.sh
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Removes unused Docker images, stopped containers, and dangling volumes.
|
||||
# Runs every 2 hours via cron — see scripts/cronjobs.md for setup.
|
||||
|
||||
LOG_FILE="/tmp/docker-cleanup.log"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
echo "[$TIMESTAMP] Starting Docker cleanup..." >> "$LOG_FILE"
|
||||
|
||||
# Remove stopped containers
|
||||
CONTAINERS=$(docker container prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Containers: $CONTAINERS" >> "$LOG_FILE"
|
||||
|
||||
# Remove dangling images (untagged layers with no container)
|
||||
DANGLING=$(docker image prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Dangling images: $DANGLING" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused images (not referenced by any container)
|
||||
IMAGES=$(docker image prune -a -f --filter "until=2h" 2>&1)
|
||||
echo "[$TIMESTAMP] Unused images: $IMAGES" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused volumes
|
||||
VOLUMES=$(docker volume prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Volumes: $VOLUMES" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused networks
|
||||
NETWORKS=$(docker network prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Networks: $NETWORKS" >> "$LOG_FILE"
|
||||
|
||||
echo "[$TIMESTAMP] Cleanup complete." >> "$LOG_FILE"
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
BACKUP_ROOT="${1:-${ROOT_DIR}/backups}"
|
||||
shift || true
|
||||
EXTRA_VOLUMES=("$@")
|
||||
TIMESTAMP="$(date -u +"%Y%m%dT%H%M%SZ")"
|
||||
mkdir -p "${BACKUP_ROOT}"
|
||||
BACKUP_ROOT="$(cd "${BACKUP_ROOT}" && pwd)"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/rentaldrivego-prod-${TIMESTAMP}"
|
||||
VOLUME_BACKUP_DIR="${BACKUP_DIR}/volumes"
|
||||
|
||||
DEFAULT_VOLUMES=(
|
||||
"${PROJECT_NAME}_api_uploads"
|
||||
"${PROJECT_NAME}_pgmanage_prod_data"
|
||||
"${PROJECT_NAME}_postgres_prod_data"
|
||||
"${PROJECT_NAME}_redis_prod_data"
|
||||
)
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
mkdir -p "${VOLUME_BACKUP_DIR}"
|
||||
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
|
||||
echo "Creating production backup in ${BACKUP_DIR}"
|
||||
|
||||
prod_compose up -d postgres >/dev/null
|
||||
|
||||
echo "Backing up PostgreSQL database..."
|
||||
prod_compose exec -T postgres sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "${BACKUP_DIR}/postgres.dump"
|
||||
|
||||
echo "Backing up uploaded files..."
|
||||
prod_compose run --rm --no-deps api bash -lc '
|
||||
mkdir -p /var/lib/rentaldrivego/storage
|
||||
tar -czf - -C /var/lib/rentaldrivego/storage .
|
||||
' > "${BACKUP_DIR}/api-uploads.tar.gz"
|
||||
|
||||
echo "Backing up Traefik certificates..."
|
||||
if traefik_compose run --rm traefik sh -lc 'test -d /letsencrypt' > /dev/null 2>&1; then
|
||||
traefik_compose run --rm traefik sh -lc '
|
||||
mkdir -p /letsencrypt
|
||||
tar -czf - -C /letsencrypt .
|
||||
' > "${BACKUP_DIR}/traefik-letsencrypt.tar.gz"
|
||||
else
|
||||
echo "Skipping Traefik certificate backup; Traefik runtime is not available."
|
||||
fi
|
||||
|
||||
declare -a ALL_VOLUMES=("${DEFAULT_VOLUMES[@]}")
|
||||
|
||||
if [[ -n "${DOCKER_EXTRA_BACKUP_VOLUMES:-}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
ALL_VOLUMES+=(${DOCKER_EXTRA_BACKUP_VOLUMES})
|
||||
fi
|
||||
|
||||
if [[ "${#EXTRA_VOLUMES[@]}" -gt 0 ]]; then
|
||||
ALL_VOLUMES+=("${EXTRA_VOLUMES[@]}")
|
||||
fi
|
||||
|
||||
echo "Backing up named Docker volumes..."
|
||||
declare -A seen_volumes=()
|
||||
for volume_name in "${ALL_VOLUMES[@]}"; do
|
||||
[[ -n "${volume_name}" ]] || continue
|
||||
if [[ -n "${seen_volumes[${volume_name}]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
seen_volumes["${volume_name}"]=1
|
||||
|
||||
if ! docker_volume_exists "${volume_name}"; then
|
||||
echo "Skipping missing volume: ${volume_name}"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " - ${volume_name}"
|
||||
backup_named_volume "${volume_name}" "${VOLUME_BACKUP_DIR}/${volume_name}.tar.gz"
|
||||
done
|
||||
|
||||
cat > "${BACKUP_DIR}/manifest.txt" <<EOF
|
||||
project=${PROJECT_NAME}
|
||||
created_at_utc=${TIMESTAMP}
|
||||
database_backup=postgres.dump
|
||||
uploads_backup=api-uploads.tar.gz
|
||||
traefik_backup=traefik-letsencrypt.tar.gz
|
||||
volume_backup_dir=volumes
|
||||
EOF
|
||||
|
||||
echo "Backup complete:"
|
||||
echo " - ${BACKUP_DIR}/postgres.dump"
|
||||
echo " - ${BACKUP_DIR}/api-uploads.tar.gz"
|
||||
if [[ -f "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" ]]; then
|
||||
echo " - ${BACKUP_DIR}/traefik-letsencrypt.tar.gz"
|
||||
fi
|
||||
if compgen -G "${VOLUME_BACKUP_DIR}/*.tar.gz" > /dev/null; then
|
||||
echo " - ${VOLUME_BACKUP_DIR}/*.tar.gz"
|
||||
fi
|
||||
echo " - ${BACKUP_DIR}/manifest.txt"
|
||||
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml"
|
||||
PORTAINER_COMPOSE_FILE="${ROOT_DIR}/docker-compose.portainer.production.yml"
|
||||
REGISTRY_COMPOSE_FILE="${ROOT_DIR}/docker-compose.registry.production.yml"
|
||||
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
|
||||
ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
||||
DYNAMIC_DIR="${ROOT_DIR}/dynamic"
|
||||
REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml"
|
||||
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
|
||||
PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}"
|
||||
REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-prod}"
|
||||
TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}"
|
||||
VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}"
|
||||
API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}"
|
||||
REGISTRY_AUTH_DIR="${ROOT_DIR}/docker/registry/auth"
|
||||
REGISTRY_AUTH_FILE="${REGISTRY_AUTH_DIR}/htpasswd"
|
||||
|
||||
ensure_env_file() {
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "Missing ${ENV_FILE}" >&2
|
||||
echo "Create it from .env.docker.production.example before starting production containers." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_prod_env_file
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local value
|
||||
|
||||
value="$(
|
||||
awk -F= -v key="$key" '
|
||||
$1 == key {
|
||||
value = substr($0, index($0, "=") + 1)
|
||||
}
|
||||
END {
|
||||
if (value != "") {
|
||||
print value
|
||||
}
|
||||
}
|
||||
' "${ENV_FILE}"
|
||||
)"
|
||||
|
||||
value="${value%$'\r'}"
|
||||
value="${value#\"}"
|
||||
value="${value%\"}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
validate_prod_env_file() {
|
||||
local api_domain public_site_domain cors_origins portainer_domain postgres_password redis_password jwt_secret
|
||||
|
||||
api_domain="$(read_env_value API_DOMAIN)"
|
||||
public_site_domain="$(read_env_value PUBLIC_SITE_DOMAIN)"
|
||||
cors_origins="$(read_env_value CORS_ORIGINS)"
|
||||
portainer_domain="$(read_env_value PORTAINER_DOMAIN)"
|
||||
postgres_password="$(read_env_value POSTGRES_PASSWORD)"
|
||||
redis_password="$(read_env_value REDIS_PASSWORD)"
|
||||
jwt_secret="$(read_env_value JWT_SECRET)"
|
||||
|
||||
if [[ -z "${api_domain}" || "${api_domain}" == *example.com* ]]; then
|
||||
echo "Invalid API_DOMAIN in ${ENV_FILE}. Set it to the real production API hostname, not example.com." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${public_site_domain}" || "${public_site_domain}" == *example.com* ]]; then
|
||||
echo "Invalid PUBLIC_SITE_DOMAIN in ${ENV_FILE}. Set it to the real production site hostname, not example.com." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${cors_origins}" || "${cors_origins}" == *example.com* ]]; then
|
||||
echo "Invalid CORS_ORIGINS in ${ENV_FILE}. Replace example.com origins with the real production domains." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for pair in "POSTGRES_PASSWORD:${postgres_password}" "REDIS_PASSWORD:${redis_password}" "JWT_SECRET:${jwt_secret}"; do
|
||||
key="${pair%%:*}"
|
||||
value="${pair#*:}"
|
||||
if [[ -z "${value}" || "${value}" == replace-with-* || "${value}" == *changeme* || "${value}" == *change-me* ]]; then
|
||||
echo "Invalid ${key} in ${ENV_FILE}. Set a real production secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
if [[ -n "${portainer_domain}" && "${portainer_domain}" == *example.com* ]]; then
|
||||
echo "Invalid PORTAINER_DOMAIN in ${ENV_FILE}. Set a real hostname or disable the portainer router." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_traefik_network() {
|
||||
if ! docker network inspect "${TRAEFIK_NETWORK}" >/dev/null 2>&1; then
|
||||
echo "Creating Docker network: ${TRAEFIK_NETWORK}"
|
||||
docker network create "${TRAEFIK_NETWORK}" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_api_uploads_volume() {
|
||||
if ! docker volume inspect "${API_UPLOADS_VOLUME}" >/dev/null 2>&1; then
|
||||
echo "Creating Docker volume: ${API_UPLOADS_VOLUME}"
|
||||
docker volume create "${API_UPLOADS_VOLUME}" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dynamic_dir() {
|
||||
mkdir -p "${DYNAMIC_DIR}"
|
||||
}
|
||||
|
||||
prod_compose() {
|
||||
docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
portainer_compose() {
|
||||
docker compose -p "${PORTAINER_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PORTAINER_COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
registry_compose() {
|
||||
docker compose -p "${REGISTRY_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${REGISTRY_COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
traefik_compose() {
|
||||
docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
start_prod_services() {
|
||||
ensure_env_file
|
||||
ensure_api_uploads_volume
|
||||
|
||||
local services=("$@")
|
||||
local needs_migrations=0
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
case "${service}" in
|
||||
api|marketplace|dashboard|admin)
|
||||
needs_migrations=1
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${needs_migrations} -eq 1 ]]; then
|
||||
prod_compose up --build -d postgres redis
|
||||
wait_for_healthy postgres 120
|
||||
prod_compose run --rm --build migrate
|
||||
fi
|
||||
|
||||
prod_compose up --build -d "$@"
|
||||
}
|
||||
|
||||
require_release_image() {
|
||||
if [[ -z "${APP_IMAGE:-}" || -z "${APP_VERSION:-}" ]]; then
|
||||
echo "APP_IMAGE and APP_VERSION must be set for pull-based production deploys." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
login_registry_if_configured() {
|
||||
local configured=0
|
||||
|
||||
[[ -n "${REGISTRY_HOST:-}" ]] && configured=$((configured + 1))
|
||||
[[ -n "${REGISTRY_USER:-}" ]] && configured=$((configured + 1))
|
||||
[[ -n "${REGISTRY_PASSWORD:-}" ]] && configured=$((configured + 1))
|
||||
|
||||
if [[ ${configured} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ${configured} -ne 3 ]]; then
|
||||
echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD must either all be set or all be unset." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin
|
||||
}
|
||||
|
||||
pull_prod_release_images() {
|
||||
require_release_image
|
||||
prod_compose pull migrate api marketplace dashboard admin
|
||||
}
|
||||
|
||||
run_prod_migrations() {
|
||||
prod_compose run --rm migrate
|
||||
}
|
||||
|
||||
wait_for_healthy() {
|
||||
local service="$1"
|
||||
local timeout="${2:-120}"
|
||||
local container="${PROJECT_NAME}-${service}-1"
|
||||
local elapsed=0
|
||||
|
||||
echo "Waiting for ${service} to be healthy (timeout: ${timeout}s)..."
|
||||
while [[ $elapsed -lt $timeout ]]; do
|
||||
local status
|
||||
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "missing")
|
||||
case "$status" in
|
||||
healthy)
|
||||
echo "${service} is healthy."
|
||||
return 0
|
||||
;;
|
||||
unhealthy)
|
||||
echo "ERROR: ${service} is unhealthy. Check logs with: docker logs ${container}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
echo "ERROR: Timed out waiting for ${service} to become healthy." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
start_portainer() {
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
portainer_compose up -d
|
||||
}
|
||||
|
||||
render_registry_proxy_config() {
|
||||
ensure_dynamic_dir
|
||||
|
||||
if [[ -z "${REGISTRY_DOMAIN:-}" && -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then
|
||||
rm -f "${REGISTRY_DYNAMIC_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -z "${REGISTRY_DOMAIN:-}" || -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then
|
||||
echo "REGISTRY_DOMAIN and REGISTRY_UPSTREAM_URL must either both be set or both be unset." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "${REGISTRY_DYNAMIC_FILE}" <<EOF
|
||||
http:
|
||||
routers:
|
||||
registry:
|
||||
rule: Host(\`${REGISTRY_DOMAIN}\`)
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: registry-upstream
|
||||
|
||||
services:
|
||||
registry-upstream:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "${REGISTRY_UPSTREAM_URL}"
|
||||
EOF
|
||||
}
|
||||
|
||||
ensure_registry_auth_file() {
|
||||
mkdir -p "${REGISTRY_AUTH_DIR}"
|
||||
|
||||
if [[ -n "${REGISTRY_USER:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||
docker run --rm --entrypoint htpasswd httpd:2-alpine -Bbn "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" > "${REGISTRY_AUTH_FILE}"
|
||||
chmod 600 "${REGISTRY_AUTH_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -f "${REGISTRY_AUTH_FILE}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Set REGISTRY_USER and REGISTRY_PASSWORD in .env.docker.production or create ${REGISTRY_AUTH_FILE} manually." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
start_registry() {
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
ensure_registry_auth_file
|
||||
registry_compose up -d
|
||||
}
|
||||
|
||||
start_traefik() {
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
render_registry_proxy_config
|
||||
traefik_compose up -d
|
||||
}
|
||||
|
||||
stop_traefik() {
|
||||
ensure_env_file
|
||||
traefik_compose down >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
docker_volume_exists() {
|
||||
docker volume inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
backup_named_volume() {
|
||||
local volume_name="$1"
|
||||
local archive_path="$2"
|
||||
|
||||
docker run --rm \
|
||||
-v "${volume_name}:/volume:ro" \
|
||||
-v "$(dirname "${archive_path}"):/backup" \
|
||||
"${VOLUME_BACKUP_IMAGE}" \
|
||||
sh -lc "tar -czf /backup/$(basename "${archive_path}") -C /volume ."
|
||||
}
|
||||
|
||||
volume_running_containers() {
|
||||
docker ps --filter "volume=$1" --format '{{.Names}}'
|
||||
}
|
||||
|
||||
restore_named_volume() {
|
||||
local volume_name="$1"
|
||||
local archive_path="$2"
|
||||
local running
|
||||
|
||||
running="$(volume_running_containers "${volume_name}")"
|
||||
if [[ -n "${running}" ]]; then
|
||||
echo "Volume ${volume_name} is currently used by running containers:" >&2
|
||||
echo "${running}" >&2
|
||||
echo "Stop those containers before restoring this volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "${volume_name}:/volume" \
|
||||
-v "$(dirname "${archive_path}"):/backup" \
|
||||
"${VOLUME_BACKUP_IMAGE}" \
|
||||
sh -lc "find /volume -mindepth 1 -delete && tar -xzf /backup/$(basename "${archive_path}") -C /volume"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
echo "Deploying production release ${APP_IMAGE:-<missing>}:${APP_VERSION:-<missing>}"
|
||||
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
ensure_api_uploads_volume
|
||||
require_release_image
|
||||
login_registry_if_configured
|
||||
|
||||
echo "Pulling release images"
|
||||
pull_prod_release_images
|
||||
|
||||
echo "Starting shared infrastructure"
|
||||
start_traefik
|
||||
prod_compose up -d postgres redis
|
||||
wait_for_healthy postgres 120
|
||||
|
||||
echo "Running database migrations"
|
||||
run_prod_migrations
|
||||
|
||||
echo "Starting application services"
|
||||
prod_compose up -d api marketplace dashboard admin
|
||||
wait_for_healthy api 180
|
||||
wait_for_healthy marketplace 180
|
||||
wait_for_healthy dashboard 180
|
||||
wait_for_healthy admin 180
|
||||
|
||||
echo "Pruning old images"
|
||||
docker image prune -f >/dev/null
|
||||
|
||||
echo "Production release ${APP_IMAGE}:${APP_VERSION} is up and healthy."
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
BACKUP_DIR="${1:-}"
|
||||
CONFIRM_FLAG="${2:-}"
|
||||
|
||||
if [[ -z "${BACKUP_DIR}" ]]; then
|
||||
echo "Usage: bash scripts/docker-prod-restore.sh <backup-dir> --yes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${CONFIRM_FLAG}" != "--yes" ]]; then
|
||||
echo "Restore is destructive and will overwrite the production database and uploaded files." >&2
|
||||
echo "Re-run with: bash scripts/docker-prod-restore.sh ${BACKUP_DIR} --yes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${BACKUP_DIR}" ]]; then
|
||||
echo "Backup directory not found: ${BACKUP_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${BACKUP_DIR}/postgres.dump" ]]; then
|
||||
echo "Missing database backup: ${BACKUP_DIR}/postgres.dump" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${BACKUP_DIR}/api-uploads.tar.gz" ]]; then
|
||||
echo "Missing uploads backup: ${BACKUP_DIR}/api-uploads.tar.gz" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_env_file
|
||||
ensure_traefik_network
|
||||
|
||||
RAW_VOLUME_DIR="${BACKUP_DIR}/volumes"
|
||||
SKIP_RAW_RESTORE_VOLUMES=(
|
||||
"${PROJECT_NAME}_api_uploads"
|
||||
"${PROJECT_NAME}_postgres_prod_data"
|
||||
)
|
||||
|
||||
echo "Stopping application services before restore..."
|
||||
prod_compose stop api marketplace dashboard admin pgmanage >/dev/null || true
|
||||
prod_compose stop redis >/dev/null || true
|
||||
stop_traefik
|
||||
|
||||
echo "Ensuring PostgreSQL is running..."
|
||||
prod_compose up -d postgres >/dev/null
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until prod_compose exec -T postgres sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Restoring PostgreSQL database..."
|
||||
cat "${BACKUP_DIR}/postgres.dump" | prod_compose exec -T postgres sh -lc '
|
||||
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges
|
||||
'
|
||||
|
||||
echo "Restoring uploaded files..."
|
||||
cat "${BACKUP_DIR}/api-uploads.tar.gz" | prod_compose run --rm --no-deps api bash -lc '
|
||||
mkdir -p /var/lib/rentaldrivego/storage
|
||||
find /var/lib/rentaldrivego/storage -mindepth 1 -delete
|
||||
tar -xzf - -C /var/lib/rentaldrivego/storage
|
||||
'
|
||||
|
||||
if [[ -f "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" ]]; then
|
||||
echo "Restoring Traefik certificates..."
|
||||
cat "${BACKUP_DIR}/traefik-letsencrypt.tar.gz" | traefik_compose run --rm traefik sh -lc '
|
||||
mkdir -p /letsencrypt
|
||||
find /letsencrypt -mindepth 1 -delete
|
||||
tar -xzf - -C /letsencrypt
|
||||
chmod 600 /letsencrypt/acme.json 2>/dev/null || true
|
||||
'
|
||||
fi
|
||||
|
||||
if [[ -d "${RAW_VOLUME_DIR}" ]]; then
|
||||
echo "Restoring raw Docker volume archives..."
|
||||
for archive_path in "${RAW_VOLUME_DIR}"/*.tar.gz; do
|
||||
[[ -e "${archive_path}" ]] || continue
|
||||
volume_name="$(basename "${archive_path}" .tar.gz)"
|
||||
|
||||
skip_volume=false
|
||||
for managed_volume in "${SKIP_RAW_RESTORE_VOLUMES[@]}"; do
|
||||
if [[ "${volume_name}" == "${managed_volume}" ]]; then
|
||||
skip_volume=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${skip_volume}" == true ]]; then
|
||||
echo "Skipping raw restore for ${volume_name}; handled by service-aware restore."
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! docker_volume_exists "${volume_name}"; then
|
||||
echo "Creating missing volume ${volume_name}"
|
||||
docker volume create "${volume_name}" >/dev/null
|
||||
fi
|
||||
|
||||
echo " - ${volume_name}"
|
||||
restore_named_volume "${volume_name}" "${archive_path}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Starting production services..."
|
||||
start_traefik
|
||||
start_prod_services
|
||||
|
||||
echo "Restore complete from ${BACKUP_DIR}"
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
APP_IMAGE="${APP_IMAGE:-registry.rentaldrivego.ma/rentaldrivego/car_management_system}"
|
||||
APP_VERSION="${APP_VERSION:-${1:-}}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-rentaldrivego_registry}"
|
||||
INCLUDE_PGMANAGE="${INCLUDE_PGMANAGE:-0}"
|
||||
POSTGRES_CONTAINER="${PROJECT_NAME}-postgres-1"
|
||||
REDIS_CONTAINER="${PROJECT_NAME}-redis-1"
|
||||
|
||||
export APP_IMAGE APP_VERSION REGISTRY_USER
|
||||
|
||||
require_release_image
|
||||
ensure_env_file
|
||||
ensure_api_uploads_volume
|
||||
|
||||
container_health_status() {
|
||||
local container_name="$1"
|
||||
docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || echo "missing"
|
||||
}
|
||||
|
||||
ensure_postgres_ready() {
|
||||
local status
|
||||
status="$(container_health_status "${POSTGRES_CONTAINER}")"
|
||||
|
||||
if [[ "${status}" == "healthy" ]]; then
|
||||
echo "Postgres is already healthy; reusing existing database container."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Starting Postgres"
|
||||
prod_compose up -d postgres
|
||||
wait_for_healthy postgres 120
|
||||
}
|
||||
|
||||
ensure_redis_ready() {
|
||||
local status
|
||||
status="$(container_health_status "${REDIS_CONTAINER}")"
|
||||
|
||||
if [[ "${status}" == "running" || "${status}" == "healthy" ]]; then
|
||||
echo "Redis is already running; leaving it in place."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Starting Redis"
|
||||
prod_compose up -d redis
|
||||
}
|
||||
|
||||
prompt_registry_password_if_needed() {
|
||||
if [[ -z "${REGISTRY_HOST:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
echo "REGISTRY_PASSWORD is not set and no interactive terminal is available for a prompt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Registry password for %s@%s: ' "${REGISTRY_USER}" "${REGISTRY_HOST}" >&2
|
||||
read -r -s REGISTRY_PASSWORD
|
||||
printf '\n' >&2
|
||||
export REGISTRY_PASSWORD
|
||||
}
|
||||
|
||||
echo "Starting production services from pulled image ${APP_IMAGE}:${APP_VERSION}"
|
||||
|
||||
prompt_registry_password_if_needed
|
||||
|
||||
echo "Logging into registry if configured"
|
||||
login_registry_if_configured
|
||||
|
||||
echo "Pulling release images"
|
||||
pull_prod_release_images
|
||||
|
||||
ensure_postgres_ready
|
||||
ensure_redis_ready
|
||||
|
||||
echo "Migration status"
|
||||
prod_compose run --rm --entrypoint sh migrate -lc 'npx prisma migrate status --schema packages/database/prisma/schema.prisma'
|
||||
|
||||
echo "Applying migrations"
|
||||
prod_compose run --rm migrate
|
||||
|
||||
echo "Starting application services"
|
||||
app_services=(api marketplace dashboard admin)
|
||||
|
||||
if [[ "${INCLUDE_PGMANAGE}" == "1" ]]; then
|
||||
prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage
|
||||
else
|
||||
prod_compose up -d "${app_services[@]}"
|
||||
fi
|
||||
wait_for_healthy api 180
|
||||
wait_for_healthy marketplace 180
|
||||
wait_for_healthy dashboard 180
|
||||
wait_for_healthy admin 180
|
||||
|
||||
echo "Production services are up from ${APP_IMAGE}:${APP_VERSION}"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services admin
|
||||
wait_for_healthy admin
|
||||
|
||||
echo "Admin is up and healthy."
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_traefik
|
||||
start_prod_services postgres redis api marketplace dashboard admin
|
||||
wait_for_healthy api
|
||||
wait_for_healthy marketplace
|
||||
wait_for_healthy dashboard
|
||||
wait_for_healthy admin
|
||||
|
||||
echo "Production stack is up and healthy: traefik, postgres, redis, api, marketplace, dashboard, admin"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services api
|
||||
wait_for_healthy api
|
||||
|
||||
echo "API is up and healthy."
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services dashboard
|
||||
wait_for_healthy dashboard
|
||||
|
||||
echo "Dashboard is up and healthy."
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services marketplace dashboard admin
|
||||
wait_for_healthy marketplace
|
||||
wait_for_healthy dashboard
|
||||
wait_for_healthy admin
|
||||
|
||||
echo "All frontend services are up and healthy."
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services marketplace
|
||||
wait_for_healthy marketplace
|
||||
|
||||
echo "Marketplace is up and healthy."
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
ensure_env_file
|
||||
|
||||
pgmanage_user="$(read_env_value PGMANAGE_DEFAULT_USERNAME)"
|
||||
pgmanage_password="$(read_env_value PGMANAGE_DEFAULT_PASSWORD)"
|
||||
if [[ -z "${pgmanage_user}" || -z "${pgmanage_password}" || "${pgmanage_user}" == "admin" || "${pgmanage_password}" == replace-with-* ]]; then
|
||||
echo "Set strong PGMANAGE_DEFAULT_USERNAME and PGMANAGE_DEFAULT_PASSWORD before starting pgManage." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting pgManage on the private Docker network only."
|
||||
echo "Access it with an SSH tunnel or VPN path to the Docker host, not through public Traefik."
|
||||
prod_compose --profile private-tools up --build -d pgmanage
|
||||
|
||||
echo "pgManage is starting as a private tool."
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_portainer
|
||||
|
||||
echo "Portainer is starting."
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services postgres
|
||||
|
||||
echo "Postgres is starting."
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_prod_services redis
|
||||
|
||||
echo "Redis is starting."
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_registry
|
||||
|
||||
echo "Registry is starting."
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/docker-prod-common.sh"
|
||||
|
||||
start_traefik
|
||||
|
||||
echo "Traefik is starting."
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.registry.local.yml"
|
||||
AUTH_DIR="${ROOT_DIR}/docker/registry/auth"
|
||||
AUTH_FILE="${AUTH_DIR}/htpasswd"
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local line
|
||||
|
||||
line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
|
||||
line="${line#*=}"
|
||||
|
||||
if [[ "${line}" == \"*\" && "${line}" == *\" ]]; then
|
||||
line="${line:1:-1}"
|
||||
elif [[ "${line}" == \'*\' && "${line}" == *\' ]]; then
|
||||
line="${line:1:-1}"
|
||||
fi
|
||||
|
||||
printf '%s' "${line}"
|
||||
}
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "Missing ${ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${AUTH_DIR}"
|
||||
|
||||
REGISTRY_USER_VALUE="$(read_env_value REGISTRY_USER)"
|
||||
REGISTRY_PASSWORD_VALUE="$(read_env_value REGISTRY_PASSWORD)"
|
||||
|
||||
if [[ -n "${REGISTRY_USER_VALUE}" && -n "${REGISTRY_PASSWORD_VALUE}" ]]; then
|
||||
docker run --rm --entrypoint htpasswd httpd:2-alpine -Bbn "${REGISTRY_USER_VALUE}" "${REGISTRY_PASSWORD_VALUE}" > "${AUTH_FILE}"
|
||||
chmod 600 "${AUTH_FILE}"
|
||||
elif [[ ! -f "${AUTH_FILE}" ]]; then
|
||||
echo "Set REGISTRY_USER and REGISTRY_PASSWORD in ${ENV_FILE} or create ${AUTH_FILE} manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -p rentaldrivego-registry-local --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" up -d
|
||||
|
||||
echo "Local registry is starting on port 5000."
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
function parseEnvFile(content) {
|
||||
const env = {}
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
|
||||
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex === -1) continue
|
||||
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
||||
|
||||
let value = normalized.slice(separatorIndex + 1)
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
const quote = value[0]
|
||||
value = value.slice(1, -1)
|
||||
|
||||
if (quote === '"') {
|
||||
value = value
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\')
|
||||
}
|
||||
}
|
||||
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
const [, , envFileArg, commandArg, ...commandArgs] = process.argv
|
||||
|
||||
if (!envFileArg || !commandArg) {
|
||||
console.error('Usage: node run-with-env-file.cjs <env-file> <command> [args...]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), envFileArg)
|
||||
const commandPath = path.resolve(process.cwd(), commandArg)
|
||||
const fileContent = fs.readFileSync(envFilePath, 'utf8')
|
||||
const parsedEnv = parseEnvFile(fileContent)
|
||||
|
||||
const child = spawn(commandPath, commandArgs, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
...parsedEnv,
|
||||
},
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
const ignoredDirs = new Set(['.git', 'node_modules', 'dist', '.next', 'coverage', '.turbo'])
|
||||
const files = []
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ignoredDirs.has(entry.name)) continue
|
||||
const p = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(p)
|
||||
else files.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
|
||||
const findings = []
|
||||
const authTokenStorage = /localStorage\.(?:setItem|getItem|removeItem)\(\s*(?:['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)['"]|[A-Z0-9_]*TOKEN[A-Z0-9_]*)|document\.cookie\s*=\s*['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)=/i
|
||||
const rawWebhookStringify = /JSON\.stringify\(req\.body\)/i
|
||||
const secretAssignment = /^(JWT_SECRET|DATABASE_URL|POSTGRES_PASSWORD|SMTP_PASSWORD|REGISTRY_PASSWORD|REGISTRY_HTTP_SECRET|AMANPAY_WEBHOOK_SECRET|PAYPAL_WEBHOOK_SECRET|CLERK_SECRET_KEY|RESEND_API_KEY|MAIL_PASSWORD|FIREBASE_PRIVATE_KEY|TWILIO_AUTH_TOKEN|PAYPAL_CLIENT_SECRET|AMANPAY_SECRET_KEY|CLOUDINARY_API_SECRET|ADMIN_SEED_PASSWORD)\s*=\s*(.+)$/i
|
||||
|
||||
function isTestPath(rel) {
|
||||
return rel.includes('/test/') || rel.includes('/tests/') || /\.(test|spec)\.[tj]sx?$/.test(rel)
|
||||
}
|
||||
|
||||
function isEnvLike(rel) {
|
||||
const base = path.basename(rel)
|
||||
return base === '.env' || base.startsWith('.env') || base.endsWith('.env')
|
||||
}
|
||||
|
||||
function looksPlaceholder(value) {
|
||||
const v = value.trim().replace(/^['"]|['"]$/g, '')
|
||||
if (!v || /^\$\{?[A-Z0-9_]+\}?$/i.test(v)) return true
|
||||
if (/replace-with|placeholder|example|your-|changeme|change-me|test-secret|localhost|127\.0\.0\.1/i.test(v)) return true
|
||||
if (/^postgresql:\/\/[^:]+:replace-with-/i.test(v)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const rel = path.relative(root, file)
|
||||
if (rel.startsWith('.claude/') || rel === '.gitlab-ci.yml' || isTestPath(rel)) continue
|
||||
if (!/\.(ts|tsx|js|jsx|json|env|example|yml|yaml|toml|md|prisma|sql)$/.test(rel) && !path.basename(rel).startsWith('.env')) continue
|
||||
|
||||
let text
|
||||
try { text = fs.readFileSync(file, 'utf8') } catch { continue }
|
||||
if (text.includes('\u0000')) continue
|
||||
const lines = text.split(/\r?\n/)
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i]
|
||||
if (authTokenStorage.test(line)) {
|
||||
findings.push(`${rel}:${i + 1}: script-readable auth token storage`)
|
||||
}
|
||||
if (rel.includes('/webhooks/') || rel.includes('payment.routes') || rel.includes('subscription.routes')) {
|
||||
if (rawWebhookStringify.test(line)) findings.push(`${rel}:${i + 1}: webhook signature code must use raw body`)
|
||||
}
|
||||
if (isEnvLike(rel)) {
|
||||
const match = line.match(secretAssignment)
|
||||
if (match && !looksPlaceholder(match[2])) {
|
||||
findings.push(`${rel}:${i + 1}: possible committed secret`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length) {
|
||||
console.error('Security static check failed:')
|
||||
for (const finding of findings) console.error(`- ${finding}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Security static check passed')
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="${ROOT_DIR}/.env.local"
|
||||
SECRET_KEY="${CLERK_SECRET_KEY:-}"
|
||||
PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:-${CLERK_PUBLISHABLE_KEY:-}}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/setup-clerk-keys.sh --secret-key sk_test_xxx --publishable-key pk_test_xxx
|
||||
|
||||
Options:
|
||||
--secret-key VALUE Clerk secret key to store as CLERK_SECRET_KEY
|
||||
--publishable-key VALUE Clerk publishable key to store as NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
--env-file PATH Target env file (default: .env.local)
|
||||
--help Show this message
|
||||
|
||||
Notes:
|
||||
- Real Clerk keys are created by Clerk, not generated offline by this script.
|
||||
- Find them in the Clerk Dashboard > API Keys.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--secret-key)
|
||||
SECRET_KEY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--publishable-key)
|
||||
PUBLISHABLE_KEY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--env-file)
|
||||
ENV_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
prompt_if_missing() {
|
||||
local var_name="$1"
|
||||
local label="$2"
|
||||
local current_value="$3"
|
||||
|
||||
if [[ -n "$current_value" ]]; then
|
||||
printf '%s' "$current_value"
|
||||
return 0
|
||||
fi
|
||||
|
||||
read -r -p "${label}: " current_value
|
||||
printf '%s' "$current_value"
|
||||
}
|
||||
|
||||
SECRET_KEY="$(prompt_if_missing "CLERK_SECRET_KEY" "Enter your Clerk secret key (sk_test_... or sk_live_...)" "$SECRET_KEY")"
|
||||
PUBLISHABLE_KEY="$(prompt_if_missing "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "Enter your Clerk publishable key (pk_test_... or pk_live_...)" "$PUBLISHABLE_KEY")"
|
||||
|
||||
validate_prefix() {
|
||||
local value="$1"
|
||||
local name="$2"
|
||||
local prefix_a="$3"
|
||||
local prefix_b="$4"
|
||||
|
||||
if [[ -z "$value" ]]; then
|
||||
echo "${name} is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$value" != "${prefix_a}"* && "$value" != "${prefix_b}"* ]]; then
|
||||
echo "${name} must start with ${prefix_a} or ${prefix_b}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_prefix "$SECRET_KEY" "CLERK_SECRET_KEY" "sk_test_" "sk_live_"
|
||||
validate_prefix "$PUBLISHABLE_KEY" "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "pk_test_" "pk_live_"
|
||||
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
touch "$ENV_FILE"
|
||||
|
||||
upsert_env_var() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local escaped_value
|
||||
escaped_value="$(printf '%s' "$value" | sed 's/[&|]/\\&/g')"
|
||||
|
||||
if grep -q "^${key}=" "$file"; then
|
||||
sed -i "s|^${key}=.*|${key}=${escaped_value}|" "$file"
|
||||
else
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
upsert_env_var "$ENV_FILE" "CLERK_SECRET_KEY" "$SECRET_KEY"
|
||||
upsert_env_var "$ENV_FILE" "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "$PUBLISHABLE_KEY"
|
||||
|
||||
echo "Updated ${ENV_FILE}"
|
||||
echo " CLERK_SECRET_KEY=${SECRET_KEY:0:10}..."
|
||||
echo " NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${PUBLISHABLE_KEY:0:10}..."
|
||||
Reference in New Issue
Block a user