From 2e9c77559911b3c7fdc4999397b85562999c4a88 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 27 Sep 2026 10:39:11 -0300 Subject: [PATCH] c --- app/sbin/ODOO_19/ODOO_19.lib | 92 ----- app/sbin/ODOO_19/audit | 3 - app/sbin/ODOO_19/dbVersion | 47 --- app/sbin/ODOO_19/import | 32 -- app/sbin/ODOO_19/listModules | 124 ------- app/sbin/ODOO_19/restore | 68 ---- app/sbin/ODOO_19/shell | 22 -- app/sbin/ODOO_19/updateModules | 60 ---- app/sbin/api | 103 +++--- app/sbin/api.orig | 591 --------------------------------- app/sbin/backup/N8N | 4 +- app/sbin/backup/ODOO | 30 +- app/sbin/backupAll | 16 +- app/sbin/backupContainer | 39 +-- app/sbin/checkBackup | 8 +- app/sbin/checkCalls | 25 +- app/sbin/checkFilestore | 32 +- app/sbin/contractInfo | 9 +- app/sbin/helpers | 72 +++- app/sbin/helpers.orig | 87 ----- app/sbin/migrate | 134 +++++++- app/sbin/nuke/ODOO_19 | 54 --- app/sbin/nukeContainer | 59 ++-- app/sbin/restartContainers | 14 +- app/sbin/rotateLogs | 8 +- app/sbin/start/ODOO_17 | 127 ------- app/sbin/start/ODOO_18 | 127 ------- app/sbin/start/ODOO_19 | 125 ------- app/sbin/start/n8n | 46 +-- app/sbin/startContainer | 59 ++-- app/templates/traefik.yaml | 2 +- app/update | 2 +- app/update_sbin | 2 +- app/vault/host_vars.img | Bin 26214400 -> 26214400 bytes 34 files changed, 390 insertions(+), 1833 deletions(-) delete mode 100644 app/sbin/ODOO_19/ODOO_19.lib delete mode 100755 app/sbin/ODOO_19/audit delete mode 100755 app/sbin/ODOO_19/dbVersion delete mode 100755 app/sbin/ODOO_19/import delete mode 100755 app/sbin/ODOO_19/listModules delete mode 100755 app/sbin/ODOO_19/restore delete mode 100755 app/sbin/ODOO_19/shell delete mode 100755 app/sbin/ODOO_19/updateModules delete mode 100755 app/sbin/api.orig delete mode 100755 app/sbin/helpers.orig delete mode 100755 app/sbin/nuke/ODOO_19 delete mode 100755 app/sbin/start/ODOO_17 delete mode 100755 app/sbin/start/ODOO_18 delete mode 100755 app/sbin/start/ODOO_19 diff --git a/app/sbin/ODOO_19/ODOO_19.lib b/app/sbin/ODOO_19/ODOO_19.lib deleted file mode 100644 index 7a63bba..0000000 --- a/app/sbin/ODOO_19/ODOO_19.lib +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash - - -dump_config (){ -echo "========== Odoo Container Configuration ==========" -echo "UUID: $UUID" -echo "BRANCH: $BRANCH" -echo "STAGING: $STAGING" -echo -echo "PostgreSQL Host: $POSTGRES_HOST" -echo "PostgreSQL Port: $POSTGRES_PORT" -echo "PostgreSQL Admin: $POSTGRES_ADMIN_USER / $POSTGRES_ADMIN_PASSWORD" -echo "ODOO DB User: $ODOO_DB_USER" -echo "ODOO DB Password: $ODOO_DB_PASSWORD" -echo -echo "BASEURL: $BASEURL" -echo "DATA_DIR: $DATA_DIR" -echo "CUSTOM_DIR: $CUSTOM_DIR" -echo "ENTERPRISE_DIR: $ENTERPRISE_DIR" -echo "LOGS_DIR: $LOGS_DIR" -echo "CONFIG_DIR: $CONFIG_DIR" -echo "CC_DIR: $CC_DIR" -echo "BACKUP_DIR: $BACKUP_DIR" -echo "GIT_DIR: $GIT_DIR" -echo "ETC_DIR: $ETC_DIR" -echo "INSTALL_DIR: $INSTALL_DIR" -echo "SSH_DIR: $SSH_DIR" -echo "HUGO_DIR: $HUGO_DIR" -echo -echo "SERVER_IP: $SERVER_IP" -echo "==================================================" - -} - - - -# ----------------------------- -# Function: Create PostgreSQL user -# ----------------------------- - -check_and_create_db() { -echo "check and create" -echo "Connecting as $POSTGRES_ADMIN_USER to $POSTGRES_HOST:$POSTGRES_PORT" - -# ----------------------------- -# Check if user exists -# ----------------------------- -USER_EXISTS=$(PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$ODOO_DB_USER';" | grep -q 1 && echo "yes" || echo "no") -if [ "$USER_EXISTS" = "no" ]; then - echo "Creating PostgreSQL user $ODOO_DB_USER..." - PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres -c "CREATE USER \"$ODOO_DB_USER\" WITH PASSWORD '$ODOO_DB_PASSWORD';" -fi - -# ----------------------------- -# Check if database exists -# ----------------------------- -DB_EXISTS=$(PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$UUID';" | grep -q 1 && echo "yes" || echo "no") - -if [ "$DB_EXISTS" = "no" ]; then - /4server/sbin/ODOO_19/restore $UUID default.zip -fi -} - - - -# ----------------------------- -# Function: Check DNS and build Traefik labels -# ----------------------------- -check_domains() { - local domains="$1" - local server_ip="$2" - - echo "Checking DNS resolution for domains: $domains" - local filtered_domains="" - for domain in $domains; do - ns_ip=$(nslookup "$domain" 2>/dev/null | grep -Eo 'Address: ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}' | tail -n1) - if [[ "$ns_ip" == "$server_ip" ]]; then - filtered_domains+=" $domain" - fi - done - filtered_domains=$(echo "$filtered_domains" | xargs) - - DOMAIN_LABEL="" - for domain in $filtered_domains; do - if [ -z "$DOMAIN_LABEL" ]; then - DOMAIN_LABEL="traefik.http.routers.$UUID.rule=Host(\`$domain\`)" - else - DOMAIN_LABEL+=" || Host(\`$domain\`)" - fi - done - echo "$DOMAIN_LABEL" -} diff --git a/app/sbin/ODOO_19/audit b/app/sbin/ODOO_19/audit deleted file mode 100755 index 1c5b49f..0000000 --- a/app/sbin/ODOO_19/audit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -/4server/sbin/ODOO_19/listModules $1 diff --git a/app/sbin/ODOO_19/dbVersion b/app/sbin/ODOO_19/dbVersion deleted file mode 100755 index ca12723..0000000 --- a/app/sbin/ODOO_19/dbVersion +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -import csv -import sys -import zipfile - -if len(sys.argv) < 2: - print("Usage: python3 check_odoo_version.py ") - sys.exit(1) - -zip_path = sys.argv[1] -base_version = None - -with zipfile.ZipFile(zip_path, 'r') as z: - # Assume there is only one .sql file in the zip - sql_files = [f for f in z.namelist() if f.endswith('.sql')] - if not sql_files: - print("No .sql file found in the zip.") - sys.exit(1) - - sql_file_name = sql_files[0] - - with z.open(sql_file_name, 'r') as f: - # Decode bytes to string - lines = (line.decode('utf-8') for line in f) - - # Skip lines until COPY command - for line in lines: - if line.startswith("COPY public.ir_module_module"): - break - - # Read the COPY data until the terminator '\.' - reader = csv.reader(lines, delimiter='\t', quotechar='"', escapechar='\\') - for row in reader: - if row == ['\\.']: # End of COPY - break - if len(row) < 12: - continue - module_name = row[7].strip() # 8th column = name - if module_name == "base": - base_version = row[11].strip() # 12th column = latest_version - break - -if base_version: - print(base_version.split(".")[0]) -else: - print("Base module not found in dump.") - diff --git a/app/sbin/ODOO_19/import b/app/sbin/ODOO_19/import deleted file mode 100755 index 8b5d6af..0000000 --- a/app/sbin/ODOO_19/import +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# Create the tmp directory if it doesn't exist -mkdir -p /4server/tmp/ - -# Save original stdout -exec 3>&1 - -# Redirect all other output to log -exec > /4server/data/log/importDb.log 2>&1 -echo "$(date '+%Y-%m-%d %H:%M') Import file $1" - -# Generate random 8-digit filename -RANDOM_FILE="/4server/tmp/import_$(date '+%Y%m%d_%H%M').zip" - -# Download file from Google Drive using gdown -gdown "$1" -O "$RANDOM_FILE" - -# Execute dbVersion on the downloaded file and capture output -VERSION=$(/4server/sbin/ODOO_19/dbVersion "$RANDOM_FILE") - -# Output JSON to original stdout -cat <&3 -{ - "version":"$VERSION", - "file":"$RANDOM_FILE" -} -EOF - -# Close saved stdout -exec 3>&- - diff --git a/app/sbin/ODOO_19/listModules b/app/sbin/ODOO_19/listModules deleted file mode 100755 index 07a4c09..0000000 --- a/app/sbin/ODOO_19/listModules +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/bash -export PATH=/4PROJECTS/bin:$PATH -if [ ! -n "$1" ]; then - echo "Missing Parameters " - exit 0 -fi - -UUID=$1 - -source /4server/sbin/helpers - -get_contract_info - -# Query installed modules from database and save CSV in variable -DB_MODULES_CSV=$(PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" PAGER= psql \ - -h "$POSTGRES_HOST" \ - -U "$POSTGRES_ADMIN_USER" \ - -p "$POSTGRES_PORT" \ - -d "$UUID" \ - --csv \ - -c " -SELECT - name, - latest_version, - author, - summary -FROM ir_module_module -WHERE state = 'installed' -ORDER BY name; -") - -# Check if container exists and is running -if ! doas docker ps --format "{{.Names}}" | grep -q "^${UUID}$"; then - echo "Error: Container $UUID is not running" >&2 - exit 1 -fi - -# Get addons_path from odoo.conf in container (try multiple possible locations) -ADDONS_PATH_RAW="" -for ODOO_CONF in "/etc/odoo.conf" "/etc/odoo/odoo.conf" "/opt/odoo/etc/odoo.conf"; do - ADDONS_PATH_RAW=$(doas docker exec "$UUID" grep "^addons_path" "$ODOO_CONF" 2>/dev/null | sed 's/.*=//' | xargs) - [ -n "$ADDONS_PATH_RAW" ] && break -done - -# Also check standard Odoo library locations -ODOO_LIB_PATHS="" -# Check standard docker installation path -if doas docker exec "$UUID" test -d "/lib/python3/dist-packages/odoo/addons" 2>/dev/null; then - ODOO_LIB_PATHS="/lib/python3/dist-packages/odoo/addons" -fi -# Also check other possible locations -for lib_path in $(doas docker exec "$UUID" find /usr/lib /usr/local/lib -maxdepth 3 -type d -path "*/python3*/site-packages/odoo/addons" 2>/dev/null); do - ODOO_LIB_PATHS="$ODOO_LIB_PATHS $lib_path" -done - -# Collect all module directories from filesystem -FILESYSTEM_MODULES=$(mktemp) -trap "rm -f $FILESYSTEM_MODULES" EXIT - -# Collect from addons_path directories -if [ -n "$ADDONS_PATH_RAW" ]; then - OLD_IFS="$IFS" - IFS=',' read -ra ADDPATHS <<< "$ADDONS_PATH_RAW" - IFS="$OLD_IFS" - for addons_dir in "${ADDPATHS[@]}"; do - addons_dir=$(echo "$addons_dir" | xargs) - [ -z "$addons_dir" ] && continue - - # Get all directories in this addons path - doas docker exec "$UUID" find "$addons_dir" -maxdepth 1 -type d 2>/dev/null | \ - sed "s|^$addons_dir/||" | sed "s|^$addons_dir$||" | grep -v "^$" >> "$FILESYSTEM_MODULES" - done -fi - -# Also collect from standard Odoo library locations -if [ -n "$ODOO_LIB_PATHS" ]; then - for lib_path in $ODOO_LIB_PATHS; do - lib_path=$(echo "$lib_path" | xargs) - [ -z "$lib_path" ] && continue - - # Get all directories in this library path - doas docker exec "$UUID" find "$lib_path" -maxdepth 1 -type d 2>/dev/null | \ - sed "s|^$lib_path/||" | sed "s|^$lib_path$||" | grep -v "^$" >> "$FILESYSTEM_MODULES" - done -fi - -# Sort and deduplicate filesystem modules -sort -u "$FILESYSTEM_MODULES" > "${FILESYSTEM_MODULES}.sorted" -mv "${FILESYSTEM_MODULES}.sorted" "$FILESYSTEM_MODULES" - -# Process database modules and find missing ones -MISSING_MODULES=$(mktemp) -trap "rm -f $FILESYSTEM_MODULES $MISSING_MODULES" EXIT - -# Process each line from database CSV (skip header) -echo "$DB_MODULES_CSV" | tail -n +2 | while IFS= read -r line; do - [ -z "$line" ] && continue - - # Extract module name from first CSV field - # Handle quoted CSV: extract first field, remove surrounding quotes - if echo "$line" | grep -q '^"'; then - # First field is quoted - extract between first quote and comma - name=$(echo "$line" | sed 's/^"\([^"]*\)".*/\1/') - else - # First field is not quoted - extract up to first comma - name=$(echo "$line" | cut -d',' -f1) - fi - - name=$(echo "$name" | xargs) - [ -z "$name" ] && continue - - # Check if module exists in filesystem - if ! grep -Fxq "$name" "$FILESYSTEM_MODULES"; then - # Module not found in filesystem - output the full CSV line - echo "$line" >> "$MISSING_MODULES" - fi -done - -# Output missing modules if any were found -if [ -s "$MISSING_MODULES" ]; then - echo "Missing modules:" - echo "$DB_MODULES_CSV" | head -n 1 # Output CSV header - cat "$MISSING_MODULES" -fi diff --git a/app/sbin/ODOO_19/restore b/app/sbin/ODOO_19/restore deleted file mode 100755 index 5e55637..0000000 --- a/app/sbin/ODOO_19/restore +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -export PATH=/4PROJECTS/bin:$PATH -if [ ! -n "$2" ]; then - echo "Missing Parameters " - exit 0 -fi - -UUID=$1 -echo "UUID: $UUID" - -source /4server/sbin/helpers - -get_contract_info - - -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') - -echo "PASSWORD $ODOO_DB_PASSWORD" - -echo "Restoring $FILENAME to $UUID" -echo "status of container" -doas docker ps -a --filter "id=$UUID" -echo "POSTGRES HOST: $POSTGRES_HOST" -BACKUP="/mnt/backup/$2" -TEMPLATE="/mnt/db_images/$2" -doas docker exec "$UUID" /bin/bash -c "[ -f $TEMPLATE ]" - -if doas docker exec "$UUID" /bin/bash -c "[ -f $BACKUP ]"; then - FILENAME="$BACKUP" -elif doas docker exec "$UUID" /bin/bash -c "[ -f $TEMPLATE ]"; then - FILENAME="$TEMPLATE" -else - echo "File not exists" - exit 0 -fi - - -### DELETE AND CREATE DATABASE -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -d postgres -c " -SELECT pg_terminate_backend(pid) FROM pg_stat_activity -WHERE datname = '$UUID' AND pid <> pg_backend_pid(); -" -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -d postgres -c " -DROP DATABASE IF EXISTS \"$UUID\"; -" - -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \ - -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres \ - -c "ALTER ROLE \"$UUID\" CREATEDB;" - - -doas docker exec "$UUID" rm -rf /home/odoo/.local/share/Odoo/filestore -doas docker exec "$UUID" rm -rf /root/.local/share/Odoo/filestore - -doas docker exec "$UUID" odoo db --db_host beedb -w "$ODOO_DB_PASSWORD" -r "$UUID" load "$UUID" $FILENAME -f - -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \ - -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres \ - -c "ALTER ROLE \"$UUID\" NOCREATEDB;" - - -doas docker exec "$UUID" cp -r /root/.local/share/Odoo/filestore /home/odoo/.local/share/Odoo/filestore -doas docker exec "$UUID" chown -R odoo:odoo /home/odoo/.local - -doas docker exec "$UUID" mkdir -p /var/lib/odoo/.local/share/Odoo/ -doas docker exec "$UUID" ln -s /home/odoo/.local/share/Odoo/filestore /var/lib/odoo/.local/share/Odoo/filestore - -docker restart "$UUID" diff --git a/app/sbin/ODOO_19/shell b/app/sbin/ODOO_19/shell deleted file mode 100755 index 340a83b..0000000 --- a/app/sbin/ODOO_19/shell +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -export PATH=/4PROJECTS/bin:$PATH -if [ ! -n "$1" ]; then - echo "Missing Parameters " - exit 0 -fi - -UUID=$1 -echo "UUID: $UUID" - -source /4server/sbin/helpers - -get_contract_info - - -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') - -echo "PASSWORD $ODOO_DB_PASSWORD" - -echo "POSTGRES HOST: $POSTGRES_HOST" - -doas docker exec -it "$UUID" odoo shell --db_host beedb --db_password="$ODOO_DB_PASSWORD" -d "$UUID" --db_user="$UUID" diff --git a/app/sbin/ODOO_19/updateModules b/app/sbin/ODOO_19/updateModules deleted file mode 100755 index 7e0b575..0000000 --- a/app/sbin/ODOO_19/updateModules +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -export PATH=/4PROJECTS/bin:$PATH - -# -------------------------------------------------- -# Validate parameter -# -------------------------------------------------- -if [ -z "$1" ]; then - echo "Missing Parameters " - exit 1 -fi - -UUID=$1 - -# -------------------------------------------------- -# Load environment helpers -# -------------------------------------------------- -source /4server/sbin/helpers - -if ! get_contract_info "$UUID"; then - echo "Error: Failed to load contract info for $UUID" >&2 - exit 1 -fi - -# -------------------------------------------------- -# Verify container exists -# -------------------------------------------------- -if ! doas docker ps -a --format "{{.Names}}" | grep -q "^${UUID}$"; then - echo "Error: Container $UUID does not exist" >&2 - exit 1 -fi - -echo "Updating all Odoo modules for database: $UUID" -echo "Using DB host: $POSTGRES_HOST" - -# -------------------------------------------------- -# Run Odoo update inside container -# -------------------------------------------------- -if ! doas docker exec -u odoo "$UUID" odoo \ - -u all \ - -d "$UUID" \ - --stop-after-init \ - --db_host="$POSTGRES_HOST" \ - --db_port="$POSTGRES_PORT" \ - --db_user="$POSTGRES_ADMIN_USER" \ - --db_password="$POSTGRES_ADMIN_PASSWORD" -then - echo "Error: Odoo module update failed for $UUID" >&2 - exit 1 -fi - -echo "Module update completed successfully for $UUID" - -# -------------------------------------------------- -# Restart container -# -------------------------------------------------- -echo "Starting container $UUID" -/4server/sbin/startContainer "$UUID" - -echo "Done." -exit 0 diff --git a/app/sbin/api b/app/sbin/api index fc2c015..cc6d8b7 100755 --- a/app/sbin/api +++ b/app/sbin/api @@ -19,7 +19,7 @@ from pydantic import BaseModel # ---------------------- Constants ---------------------- DB_PATH = "/4server/data/contracts.db" -BIN_PATH = "/4server/sbin" +BIN_PATH = os.getenv("SBIN_PATH", "/usr/bin") API_KEY = os.getenv("API_KEY", "your-secret-api-key") VERSION = "API: 0.0.9" @@ -58,11 +58,11 @@ def init_db(): CREATE TABLE IF NOT EXISTS containers ( ID INTEGER PRIMARY KEY AUTOINCREMENT, UUID CHAR(50) UNIQUE, + container_id CHAR(50) UNIQUE, email CHAR(100), expires DATE, tags TEXT, env TEXT, - affiliate CHAR(30), image CHAR(50), history TEXT, comment TEXT, @@ -110,7 +110,7 @@ class ContractItem(BaseModel): class ContainerModel(BaseModel): - UUID: str + container_id: str email: Optional[str] = None expires: Optional[str] = None tags: Optional[str] = None @@ -133,12 +133,12 @@ class ContainerModel(BaseModel): env_list: Optional[str] = None -class UUIDRequest(BaseModel): - UUID: str +class ContainerRequest(BaseModel): + container_id: str class CommandRequest(BaseModel): - uuid: str + container_id: str method: int @@ -153,7 +153,7 @@ class MoveRequest(BaseModel): class AddKeyRequest(BaseModel): key: str - uuid: str + container_id: str class ImageRequest(BaseModel): @@ -186,19 +186,20 @@ def redirect_to_odoo(): def update_container(request: ContainerModel): # Fetch existing record existing = execute_db( - "SELECT * FROM containers WHERE UUID = ?", (request.UUID,), fetch=True + "SELECT * FROM containers WHERE UUID = ?", (request.container_id,), fetch=True ) if not existing: # If record does not exist, insert a new one with all given fields execute_db( """ - INSERT INTO containers (UUID, email, expires, tags, image, history, + INSERT INTO containers (UUID, container_id, email, expires, tags, image, history, comment, domains, status, created, bump, git, backup_slots, hdd, workers, utm_source, utm_medium, utm_campaign, domains_slots, secret_list, env_list) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - request.UUID, + request.container_id, + request.container_id, request.email, request.expires, request.tags, @@ -221,7 +222,7 @@ def update_container(request: ContainerModel): request.env_list, ), ) - return {"UUID": request.UUID, "status": "created"} + return {"container_id": request.container_id, "status": "created"} # Existing record found, do partial update existing = existing[0] # Assuming fetch returns list of dicts @@ -230,7 +231,7 @@ def update_container(request: ContainerModel): # Only add fields that are not None in the request for field in ContainerModel.__fields__: - if field == "UUID": + if field == "container_id": continue value = getattr(request, field) if value is not None: @@ -238,22 +239,24 @@ def update_container(request: ContainerModel): params.append(value) if updates: - # Build SQL dynamically + # Build SQL dynamically; always keep UUID in sync with container_id set_clause = ", ".join(f"{k}=?" for k in updates.keys()) - params.append(request.UUID) # UUID for WHERE clause + set_clause += ", UUID=?" + params.append(request.container_id) # value for UUID=? + params.append(request.container_id) # value for WHERE UUID=? execute_db(f"UPDATE containers SET {set_clause} WHERE UUID=?", tuple(params)) return { - "UUID": request.UUID, + "container_id": request.container_id, "status": "updated", "fields_updated": list(updates.keys()), } - return {"UUID": request.UUID, "status": "no_change"} + return {"container_id": request.container_id, "status": "no_change"} @app.post("/container/start", dependencies=[Depends(verify_api_key)]) -def start_container(request: UUIDRequest): - return {"message": run_command([f"{BIN_PATH}/startContainer", request.UUID])} +def start_container(request: ContainerRequest): + return {"message": run_command([f"{BIN_PATH}/startContainer", request.container_id])} @app.post("/container/restartAllContainers", dependencies=[Depends(verify_api_key)]) @@ -262,30 +265,30 @@ def start_container(request: ImageRequest): @app.post("/container/stop", dependencies=[Depends(verify_api_key)]) -def stop_container(request: UUIDRequest): +def stop_container(request: ContainerRequest): try: - return {"message": run_command([f"{BIN_PATH}/stopContainer", request.UUID])} + return {"message": run_command([f"{BIN_PATH}/stopContainer", request.container_id])} except HTTPException: # Command failed, but continue without error to the API return {"message": "Container stop command executed (may have failed)"} @app.post("/container/nuke", dependencies=[Depends(verify_api_key)]) -def nuke_container(request: UUIDRequest): +def nuke_container(request: ContainerRequest): status = execute_db( - "SELECT status FROM containers WHERE UUID=?", (request.UUID,), fetch=True + "SELECT status FROM containers WHERE UUID=?", (request.container_id,), fetch=True ) if not status or status[0]["status"] != "nuke": raise HTTPException(400, "Container status is not 'nuke'") - return {"message": run_command([f"{BIN_PATH}/nukeContainer", request.UUID])} + return {"message": run_command([f"{BIN_PATH}/nukeContainer", request.container_id])} @app.post("/container/info", dependencies=[Depends(verify_api_key)]) -def info_container(request: Optional[UUIDRequest] = None): +def info_container(request: Optional[ContainerRequest] = None): # Fields to select fields = [ "ID", - "UUID", + "container_id", "email", "expires", "tags", @@ -311,7 +314,7 @@ def info_container(request: Optional[UUIDRequest] = None): if request: rows = execute_db( f"SELECT {field_str} FROM containers WHERE UUID=?", - (str(request.UUID),), + (str(request.container_id),), fetch=True, ) else: @@ -334,20 +337,20 @@ def info_container(request: Optional[UUIDRequest] = None): @app.post("/container/bump", dependencies=[Depends(verify_api_key)]) -def bump_container(request: UUIDRequest): +def bump_container(request: ContainerRequest): today = datetime.utcnow().strftime("%Y-%m-%d") - execute_db("UPDATE containers SET bump=? WHERE UUID=?", (today, request.UUID)) - msg = run_command([f"{BIN_PATH}/bumpContainer", request.UUID]) + execute_db("UPDATE containers SET bump=? WHERE UUID=?", (today, request.container_id)) + msg = run_command([f"{BIN_PATH}/bumpContainer", request.container_id]) return {"message": msg, "bump_date": today} @app.post("/container/quota", dependencies=[Depends(verify_api_key)]) -def container_quota(request: UUIDRequest): +def container_quota(request: ContainerRequest): output = run_command( [ "docker", "stats", - request.UUID, + request.container_id, "--no-stream", "--format", "{{.MemUsage}},{{.BlockIO}}", @@ -420,9 +423,9 @@ def restart_all_containers(request: ImageRequest): @app.post("/client/git", dependencies=[Depends(verify_api_key)]) def git_tool(request: CommandRequest): if request.method == 1: - command = [f"{BIN_PATH}/gitPull", request.uuid] + command = [f"{BIN_PATH}/gitPull", request.container_id] elif request.method == 2: - command = [f"{BIN_PATH}/gitRevert", request.uuid] + command = [f"{BIN_PATH}/gitRevert", request.container_id] else: raise HTTPException(status_code=400, detail="Invalid method") @@ -432,10 +435,10 @@ def git_tool(request: CommandRequest): @app.post("/client/addGitKey", dependencies=[Depends(verify_api_key)]) def add_git_key(request: AddKeyRequest): - if not request.key or not request.uuid: - raise HTTPException(status_code=400, detail="Key and uuid are required") + if not request.key or not request.container_id: + raise HTTPException(status_code=400, detail="Key and container_id are required") - file_path = f"/4server/data/{request.uuid}/git-server/keys/id_rsa.pub" + file_path = f"/4server/data/{request.container_id}/git-server/keys/id_rsa.pub" try: # Ensure the directory exists @@ -446,26 +449,26 @@ def add_git_key(request: AddKeyRequest): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save key: {str(e)}") - return {"message": f"Key saved for container {request.uuid}", "file": file_path} + return {"message": f"Key saved for container {request.container_id}", "file": file_path} -@app.get("/client/audit/{uuid}", dependencies=[Depends(verify_api_key)]) -def audit_odoo(uuid: str): - return {"message": run_command([f"{BIN_PATH}/ODOO_19/listModules", uuid])} +@app.get("/client/audit/{container_id}", dependencies=[Depends(verify_api_key)]) +def audit_odoo(container_id: str): + return {"message": run_command([f"{BIN_PATH}/ODOO/listModules", container_id])} -@app.get("/client/logs/{uuid}", dependencies=[Depends(verify_api_key)]) -async def get_odoo_log_summary(uuid: str): - if not re.fullmatch(r"[0-9a-fA-F\-]+", uuid): +@app.get("/client/logs/{container_id}", dependencies=[Depends(verify_api_key)]) +async def get_odoo_log_summary(container_id: str): + if not re.fullmatch(r"[a-zA-Z0-9_-]+", container_id): raise HTTPException( status_code=400, - detail="Invalid UUID format. Only numbers, letters a-f, and '-' are allowed.", + detail="Invalid container_id format. Only letters, numbers, hyphens, and underscores are allowed.", ) BASE_LOG_DIR = "/4server/data" # Build file paths as strings - project_dir = os.path.join(BASE_LOG_DIR, uuid) + project_dir = os.path.join(BASE_LOG_DIR, container_id) odoo_log_file = os.path.join(project_dir, "logs", "odoo.log") git_log_file = os.path.join(project_dir, "logs", "git.log") @@ -510,7 +513,7 @@ async def get_odoo_log_summary(uuid: str): last_50_git_lines = read_last_lines(git_log_file, 50) return { - "uuid": str(uuid), + "container_id": str(container_id), "important_odoo_log_lines": important_odoo_lines, "last_git_log_lines": last_50_git_lines, } @@ -524,7 +527,7 @@ def backup_import(request: ImportRequest): if not request.filename: raise HTTPException(status_code=400, detail="Filename is required") - command = [f"{BIN_PATH}/ODOO_19/import", request.filename] + command = [f"{BIN_PATH}/ODOO/import", request.filename] output = run_command(command) return {"message": output} @@ -551,12 +554,12 @@ def backup_move(request: MoveRequest): class BackupCheckRequest(BaseModel): - uuid: str + container_id: str @app.post("/backup/check", dependencies=[Depends(verify_api_key)]) def backup_check(request: BackupCheckRequest): - return {"message": run_command([f"{BIN_PATH}/checkBackup", request.uuid])} + return {"message": run_command([f"{BIN_PATH}/checkBackup", request.container_id])} @app.get("/backup/all", dependencies=[Depends(verify_api_key)]) diff --git a/app/sbin/api.orig b/app/sbin/api.orig deleted file mode 100755 index 43f5506..0000000 --- a/app/sbin/api.orig +++ /dev/null @@ -1,591 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import re -import sqlite3 -import subprocess -import time -from collections import deque -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, Optional - -import psutil -import uvicorn -from fastapi import Depends, FastAPI, HTTPException, Response -from fastapi.responses import PlainTextResponse, RedirectResponse -from fastapi.security.api_key import APIKeyHeader -from pydantic import BaseModel - -# ---------------------- Constants ---------------------- -DB_PATH = "/4server/data/contracts.db" -BIN_PATH = "/4server/sbin" -API_KEY = os.getenv("API_KEY", "your-secret-api-key") -VERSION = "API: 0.0.9" - -# ---------------------- FastAPI App ---------------------- -app = FastAPI() -api_key_header = APIKeyHeader(name="X-API-Key") - - -def verify_api_key(key: str = Depends(api_key_header)): - if key != API_KEY: - raise HTTPException(status_code=403, detail="Unauthorized") - - -# ---------------------- Helpers ---------------------- -def run_command(cmd: list[str]) -> str: - """Run a shell command and return stdout or raise HTTPException on error.""" - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - error_msg = ( - f"Command failed\n" - f"Command: {' '.join(cmd)}\n" - f"Return code: {result.returncode}\n" - f"Stdout: {result.stdout.strip()}\n" - f"Stderr: {result.stderr.strip() or 'None'}" - ) - raise HTTPException(status_code=500, detail=error_msg) - return result.stdout.strip() - - -def init_db(): - """Initialize the database with containers table.""" - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS containers ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - UUID CHAR(50) UNIQUE, - email CHAR(100), - expires DATE, - tags TEXT, - env TEXT, - affiliate CHAR(30), - image CHAR(50), - history TEXT, - comment TEXT, - domains TEXT, - status CHAR(20), - created DATE, - bump DATE, - secret TEXT, - contract TEXT, - git integer, - backup_slots integer, - hdd integer, - workers integer, - utm_source text, - utm_medium text, - utm_campaign text, - domains_slots integer, - secret_list TEXT, - env_list TEXT - ) - """) - conn.commit() - conn.close() - - -def execute_db(query: str, params: tuple = (), fetch: bool = False): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute(query, params) - conn.commit() - data = cursor.fetchall() if fetch else None - conn.close() - if data and fetch: - return [dict(row) for row in data] - return None - - -# ---------------------- Models ---------------------- -class ContractItem(BaseModel): - quantity: int - name: str - product_id: int - features: Dict[str, Any] - - -class ContainerModel(BaseModel): - UUID: str - email: Optional[str] = None - expires: Optional[str] = None - tags: Optional[str] = None - image: Optional[str] = None - history: Optional[str] = None - comment: Optional[str] = None - domains: Optional[str] = None - status: Optional[str] = None - created: Optional[str] = None - bump: Optional[str] = None - git: Optional[int] = None - backup_slots: Optional[int] = None - hdd: Optional[int] = None - workers: Optional[int] = None - utm_source: Optional[str] = None - utm_medium: Optional[str] = None - utm_campaign: Optional[str] = None - domains_slots: Optional[int] = None -<<<<<<< HEAD - secret_list: Optional[str] = None -======= ->>>>>>> c583649 (hmm) - env_list: Optional[str] = None - - -class UUIDRequest(BaseModel): - UUID: str - - -class CommandRequest(BaseModel): - uuid: str - method: int - - -class ImportRequest(BaseModel): - filename: str - - -class MoveRequest(BaseModel): - source: str - destination: str - - -class AddKeyRequest(BaseModel): - key: str - uuid: str - - -class ImageRequest(BaseModel): - image: str - - -# ---------------------- Routes ---------------------- -@app.get("/", include_in_schema=False) -def redirect_to_odoo(): - return RedirectResponse(url="https://ODOO4PROJECTS.com") - - -import json -from typing import Any, Dict, Optional - -from fastapi import Depends, FastAPI -from fastapi.responses import RedirectResponse -from pydantic import BaseModel - -app = FastAPI() - - -# ---------------------- Routes ---------------------- -@app.get("/", include_in_schema=False) -def redirect_to_odoo(): - return RedirectResponse(url="https://ODOO4PROJECTS.com") - - -@app.post("/container/update", dependencies=[Depends(verify_api_key)]) -def update_container(request: ContainerModel): - # Fetch existing record - existing = execute_db( - "SELECT * FROM containers WHERE UUID = ?", (request.UUID,), fetch=True - ) - if not existing: - # If record does not exist, insert a new one with all given fields - execute_db( - """ - INSERT INTO containers (UUID, email, expires, tags, image, history, - comment, domains, status, created, bump, git, backup_slots, hdd, workers, -<<<<<<< HEAD - utm_source, utm_medium, utm_campaign, domains_slots, secret_list, env_list) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -======= - utm_source, utm_medium, utm_campaign, domains_slots, env_list) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ->>>>>>> c583649 (hmm) - """, - ( - request.UUID, - request.email, - request.expires, - request.tags, - request.image, - request.history, - request.comment, - request.domains, - request.status, - request.created, - request.bump, - request.git, - request.backup_slots, - request.hdd, - request.workers, - request.utm_source, - request.utm_medium, - request.utm_campaign, - request.domains_slots, -<<<<<<< HEAD - request.secret_list, -======= ->>>>>>> c583649 (hmm) - request.env_list, - ), - ) - return {"UUID": request.UUID, "status": "created"} - - # Existing record found, do partial update - existing = existing[0] # Assuming fetch returns list of dicts - updates = {} - params = [] - - # Only add fields that are not None in the request - for field in ContainerModel.__fields__: - if field == "UUID": - continue - value = getattr(request, field) - if value is not None: - updates[field] = value - params.append(value) - - if updates: - # Build SQL dynamically - set_clause = ", ".join(f"{k}=?" for k in updates.keys()) - params.append(request.UUID) # UUID for WHERE clause - execute_db(f"UPDATE containers SET {set_clause} WHERE UUID=?", tuple(params)) - return { - "UUID": request.UUID, - "status": "updated", - "fields_updated": list(updates.keys()), - } - - return {"UUID": request.UUID, "status": "no_change"} - - -@app.post("/container/start", dependencies=[Depends(verify_api_key)]) -def start_container(request: UUIDRequest): - return {"message": run_command([f"{BIN_PATH}/startContainer", request.UUID])} - - -@app.post("/container/restartAllContainers", dependencies=[Depends(verify_api_key)]) -def start_container(request: ImageRequest): - return {"message": run_command([f"{BIN_PATH}/restartContainers", request.image])} - - -@app.post("/container/stop", dependencies=[Depends(verify_api_key)]) -def stop_container(request: UUIDRequest): - try: - return {"message": run_command([f"{BIN_PATH}/stopContainer", request.UUID])} - except HTTPException: - # Command failed, but continue without error to the API - return {"message": "Container stop command executed (may have failed)"} - - -@app.post("/container/nuke", dependencies=[Depends(verify_api_key)]) -def nuke_container(request: UUIDRequest): - status = execute_db( - "SELECT status FROM containers WHERE UUID=?", (request.UUID,), fetch=True - ) - if not status or status[0]["status"] != "nuke": - raise HTTPException(400, "Container status is not 'nuke'") - return {"message": run_command([f"{BIN_PATH}/nukeContainer", request.UUID])} - - -@app.post("/container/info", dependencies=[Depends(verify_api_key)]) -def info_container(request: Optional[UUIDRequest] = None): - # Fields to select - fields = [ - "ID", - "UUID", - "email", - "expires", - "tags", - "image", - "history", - "comment", - "domains", - "status", - "created", - "git", - "backup_slots", - "hdd", - "workers", - "utm_source", - "utm_medium", - "utm_campaign", - "domains_slots", - "env_list", - ] - field_str = ", ".join(fields) - - # Execute query - if request: - rows = execute_db( - f"SELECT {field_str} FROM containers WHERE UUID=?", - (str(request.UUID),), - fetch=True, - ) - else: - rows = execute_db(f"SELECT {field_str} FROM containers", fetch=True) - - # Map rows to dicts safely - containers = [] - for row in rows: - if isinstance(row, dict): - # Already a dict (e.g., some DB wrappers) - containers.append(row) - else: - # Tuple/list -> map with fields - containers.append(dict(zip(fields, row))) - - # Wrap in n8n JSON format - n8n_items = [{"json": container} for container in containers] - - return n8n_items - - -@app.post("/container/bump", dependencies=[Depends(verify_api_key)]) -def bump_container(request: UUIDRequest): - today = datetime.utcnow().strftime("%Y-%m-%d") - execute_db("UPDATE containers SET bump=? WHERE UUID=?", (today, request.UUID)) - msg = run_command([f"{BIN_PATH}/bumpContainer", request.UUID]) - return {"message": msg, "bump_date": today} - - -@app.post("/container/quota", dependencies=[Depends(verify_api_key)]) -def container_quota(request: UUIDRequest): - output = run_command( - [ - "docker", - "stats", - request.UUID, - "--no-stream", - "--format", - "{{.MemUsage}},{{.BlockIO}}", - ] - ) - mem_usage, disk_usage = output.split(",") - return {"memory_usage": mem_usage, "disk_io": disk_usage} - - -# ---------------------- SYSTEM ---------------------- -@app.get("/system/containers", dependencies=[Depends(verify_api_key)]) -def get_containers(): - return Response( - content=run_command([f"{BIN_PATH}/getContainers"]), - media_type="application/json", - ) - - -@app.get("/system/images", dependencies=[Depends(verify_api_key)]) -def list_images(): - images = run_command(["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"]) - return {"images": images.split("\n")} - - -@app.get("/system/cpu", dependencies=[Depends(verify_api_key)]) -def get_cpu_log(): - CPU_LOG_PATH = Path("/4server/data/log/cpu.log") - if not CPU_LOG_PATH.exists(): - raise HTTPException(status_code=404, detail="CPU log file not found") - - try: - with CPU_LOG_PATH.open("r") as f: - content = f.read() - return PlainTextResponse(content) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reading CPU log: {e}") - - -@app.get("/system/info", dependencies=[Depends(verify_api_key)]) -def get_system_info(): - try: - alpine_version = None - last_update = None - bump_dates = execute_db( - "SELECT MAX(bump) AS latest_bump FROM containers", fetch=True - )[0]["latest_bump"] - if os.path.exists("/4server/data/update"): - with open("/4server/data/update") as f: - last_update = f.read().strip() - if os.path.exists("/etc/alpine-release"): - with open("/etc/alpine-release") as f: - alpine_version = f.read().strip() - mem = psutil.virtual_memory() - disk = psutil.disk_usage("/") - cpu_count = psutil.cpu_count(logical=True) - return { - "alpine_version": alpine_version, - "last_update": last_update, - "latest_bump": bump_dates, - "version": VERSION, - "resources": { - "memory": { - "total": mem.total, - "available": mem.available, - "used": mem.used, - }, - "disk": {"total": disk.total, "used": disk.used, "free": disk.free}, - "cpu_count": cpu_count, - }, - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/system/pull", dependencies=[Depends(verify_api_key)]) -def pull_all_images(): - return {"message": run_command([f"{BIN_PATH}/pullAllContainers"])} - - -@app.post("/system/restartAllContainers", dependencies=[Depends(verify_api_key)]) -def restart_all_containers(request: ImageRequest): - if not request.image: - raise HTTPException(status_code=400, detail="Image name is required") - return {"message": run_command([f"{BIN_PATH}/restartContainers", request.image])} - - -@app.post("/client/git", dependencies=[Depends(verify_api_key)]) -def git_tool(request: CommandRequest): - if request.method == 1: - command = [f"{BIN_PATH}/gitPull", request.uuid] - elif request.method == 2: - command = [f"{BIN_PATH}/gitRevert", request.uuid] - else: - raise HTTPException(status_code=400, detail="Invalid method") - - output = run_command(command) - return {"message": output} - - -@app.post("/client/addGitKey", dependencies=[Depends(verify_api_key)]) -def add_git_key(request: AddKeyRequest): - if not request.key or not request.uuid: - raise HTTPException(status_code=400, detail="Key and uuid are required") - - file_path = f"/4server/data/{request.uuid}/git-server/keys/id_rsa.pub" - - try: - # Ensure the directory exists - os.makedirs(os.path.dirname(file_path), exist_ok=True) - # Write the key to the file - with open(file_path, "w", encoding="utf-8") as f: - f.write(request.key) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save key: {str(e)}") - - return {"message": f"Key saved for container {request.uuid}", "file": file_path} - - -@app.get("/client/audit/{uuid}", dependencies=[Depends(verify_api_key)]) -def audit_odoo(uuid: str): - return {"message": run_command([f"{BIN_PATH}/ODOO_19/listModules", uuid])} - - -@app.get("/client/logs/{uuid}", dependencies=[Depends(verify_api_key)]) -async def get_odoo_log_summary(uuid: str): - if not re.fullmatch(r"[0-9a-fA-F\-]+", uuid): - raise HTTPException( - status_code=400, - detail="Invalid UUID format. Only numbers, letters a-f, and '-' are allowed.", - ) - - BASE_LOG_DIR = "/4server/data" - - # Build file paths as strings - project_dir = os.path.join(BASE_LOG_DIR, uuid) - odoo_log_file = os.path.join(project_dir, "logs", "odoo.log") - git_log_file = os.path.join(project_dir, "logs", "git.log") - - if not os.path.isfile(odoo_log_file): - raise HTTPException(status_code=404, detail="Odoo log file not found") - - # --- Helper variables and functions --- - IMPORTANT_PATTERNS = [ - re.compile(r"odoo\.addons\."), # Any Odoo addon log - re.compile(r"Job '.*' starting"), # Cron job start - re.compile(r"Job '.*' fully done"), # Cron job end - re.compile(r"ERROR"), # Errors - re.compile(r"WARNING"), # Warnings - re.compile(r"Traceback"), # Tracebacks - re.compile(r"Error"), - ] - - def is_important_line(line: str) -> bool: - return any(p.search(line) for p in IMPORTANT_PATTERNS) - - def read_last_lines(file_path: str, max_lines: int) -> list[str]: - """ - Read the last `max_lines` from the file efficiently. - """ - if not os.path.isfile(file_path): - return [] - last_lines = deque(maxlen=max_lines) - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - last_lines.append(line.strip()) - return list(last_lines) - - # --- Main logic --- - try: - # Last 500 lines from Odoo log - last_500_lines = read_last_lines(odoo_log_file, 500) - important_odoo_lines = [ - line for line in last_500_lines if is_important_line(line) - ] - - # Last 50 lines from git.log - last_50_git_lines = read_last_lines(git_log_file, 50) - - return { - "uuid": str(uuid), - "important_odoo_log_lines": important_odoo_lines, - "last_git_log_lines": last_50_git_lines, - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reading log files: {e}") - - -# ------------------------ BACKUP HANDLING ------------------------------------- -@app.post("/backup/import", dependencies=[Depends(verify_api_key)]) -def backup_import(request: ImportRequest): - if not request.filename: - raise HTTPException(status_code=400, detail="Filename is required") - - command = [f"{BIN_PATH}/ODOO_19/import", request.filename] - output = run_command(command) - return {"message": output} - - -@app.post("/backup/move", dependencies=[Depends(verify_api_key)]) -def backup_move(request: MoveRequest): - if not request.source or not request.destination: - raise HTTPException( - status_code=400, detail="Source and destination are required" - ) - - if not os.path.exists(request.source): - raise HTTPException(status_code=404, detail="Source file does not exist") - - # Use shell command to move the file - command = ["mv", request.source, request.destination] # Linux/macOS - # For Windows, use: command = ["move", request.source, request.destination] - - output = run_command(command) - return { - "message": f"Moved {request.source} to {request.destination}", - "output": output, - } - - -@app.get("/backup/borgpush", dependencies=[Depends(verify_api_key)]) -def backup_borgpush(): - return {"message": run_command(["doas", "-u", "4server", f"{BIN_PATH}/borgpush"])} - - -# ---------------------- Entry Point ---------------------- -if __name__ == "__main__": - print(VERSION) - init_db() - time.sleep(25) - uvicorn.run(app, host="10.5.0.1", port=8888) diff --git a/app/sbin/backup/N8N b/app/sbin/backup/N8N index ce203b4..642d2cb 100755 --- a/app/sbin/backup/N8N +++ b/app/sbin/backup/N8N @@ -4,10 +4,10 @@ set -euo pipefail # Fail on error, undefined variables, and pipe errors # Create backup filename FILENAME="$(date +"%Y%m%d_%H%M").zip" -BACKUP_DIR="/BACKUP/$UUID" +BACKUP_DIR="/BACKUP/$CONTAINER_ID" mkdir -p "$BACKUP_DIR" -tar -czvf "$BACKUP_DIR/$FILENAME" -C "/4server/data" "$UUID" +tar -czvf "$BACKUP_DIR/$FILENAME" -C "/4server/data" "$CONTAINER_ID" # Remove old backups beyond the configured slots ls -t "$BACKUP_DIR"/[0-9]*.zip 2>/dev/null | tail -n +$((BACKUP_SLOTS + 1)) | while read -r file; do diff --git a/app/sbin/backup/ODOO b/app/sbin/backup/ODOO index 16c1742..a3d5e80 100755 --- a/app/sbin/backup/ODOO +++ b/app/sbin/backup/ODOO @@ -4,21 +4,27 @@ set -euo pipefail # Fail on error, undefined variables, and pipe errors -log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } +# Load helper functions (provides log/warn/die) +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN_ROOT="$(dirname "$(dirname "$_self")")" # parent of backup/ +source "$SBIN_ROOT/helpers" -# Load helper functions -source /4server/sbin/helpers +# Load SOFTWARE and DB_NAME from container.env +load_container_config "$CONTAINER_ID" # Get contract info (also exports ODOO_DB_PASSWORD via helpers) get_contract_info -log "UUID: $UUID" +# DB_NAME is the postgres database name (may differ from CONTAINER_ID for migrated containers) +DB_NAME="${DB_NAME:-$CONTAINER_ID}" + +log "CONTAINER_ID: $CONTAINER_ID" +log "DB_NAME: $DB_NAME" log "Retention: daily 14d, weekly 12w" log "DB host: $POSTGRES_HOST" # Build paths FILENAME="$(date +"%Y%m%d_%H%M").zip" -BACKUP_DIR="/BACKUP/$UUID" +BACKUP_DIR="/BACKUP/$CONTAINER_ID" BB_DIR="$BACKUP_DIR/bb" # Ensure backup directories exist (requires elevated permissions like other ops) @@ -27,12 +33,12 @@ doas mkdir -p "$BB_DIR" # Perform database dump inside the container log "Starting dump -> $BACKUP_DIR/$FILENAME" -doas docker exec "$UUID" odoo db \ +doas docker exec "$CONTAINER_ID" odoo db \ --db_host "$POSTGRES_HOST" \ - -r "$UUID" \ + -r "$DB_NAME" \ -w "$ODOO_DB_PASSWORD" \ --data-dir /home/odoo/.local/share/Odoo/ \ - dump "$UUID" "/mnt/backup/$FILENAME" + dump "$DB_NAME" "/mnt/backup/$FILENAME" log "Dump complete: $(doas du -sh "$BACKUP_DIR/$FILENAME" | cut -f1)" @@ -40,7 +46,7 @@ log "Dump complete: $(doas du -sh "$BACKUP_DIR/$FILENAME" | cut -f1)" doas chmod 666 "$BACKUP_DIR"/[0-9]*.zip # Hand ownership back to odoo inside the container -doas docker exec "$UUID" chown odoo:odoo -R /mnt/backup +doas docker exec "$CONTAINER_ID" chown odoo:odoo -R /mnt/backup # Update the current.zip symlink inside bb/ pointing at the backup just created. # Target is relative (../$FILENAME) because the symlink lives one level deeper @@ -97,10 +103,10 @@ done log "Backup finished successfully ." # --- Custom modules snapshot --- -# Archive /4server/data/$UUID/git-server/repos/local/custom/ into .current.tar.gz. +# Archive /4server/data/$CONTAINER_ID/git-server/repos/local/custom/ into .current.tar.gz. # Rebuilt only when at least one file inside is newer than the existing archive, # so borg backup dedup sees an identical blob on unchanged runs. -CUSTOM_SRC="/4server/data/$UUID/git-server/repos/local/custom" +CUSTOM_SRC="/4server/data/$CONTAINER_ID/git-server/repos/local/custom" CUSTOM_ARCHIVE="$BB_DIR/current_modules.tar.gz" if [ ! -d "$CUSTOM_SRC" ]; then @@ -110,7 +116,7 @@ elif [ ! -f "$CUSTOM_ARCHIVE" ] || \ log "Changes detected in custom modules — rebuilding current.tar.gz" # Write to a temp file first; mv is atomic, so borg never sees a partial archive doas tar -czf "${CUSTOM_ARCHIVE}.tmp" \ - -C "/4server/data/$UUID/git-server/repos/local" custom + -C "/4server/data/$CONTAINER_ID/git-server/repos/local" custom doas mv "${CUSTOM_ARCHIVE}.tmp" "$CUSTOM_ARCHIVE" log ".current.tar.gz updated: $(doas du -sh "$CUSTOM_ARCHIVE" | cut -f1)" else diff --git a/app/sbin/backupAll b/app/sbin/backupAll index 485a43c..456b888 100755 --- a/app/sbin/backupAll +++ b/app/sbin/backupAll @@ -1,18 +1,20 @@ #!/bin/bash exec >> /4server/data/log/backupAll.log 2>&1 -echo "$(date '+%Y-%m-%d %H:%M') Starting backupAll" +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" +log "Starting backupAll" -BIN_PATH="/4server/sbin" +BIN_PATH="$SBIN" -containers=$(docker ps --format '{{.Names}}' | grep -E '^[0-9]{3}-[0-9]{3}') +containers=$(docker ps --format '{{.Names}}' | grep -v '^traefik$' | grep -v '^beedb$') -if [[ -z "$containers" ]]; then - echo "No containers matching pattern NNN-NNN found." +if [ -z "$containers" ]; then + log "No managed container_ids found." exit 0 fi -echo "Found containers:" +log "Triggering backup for:" while IFS= read -r container; do - echo " $container" + log " $container" touch "/4server/data/$container/cc/backup" done <<< "$containers" diff --git a/app/sbin/backupContainer b/app/sbin/backupContainer index dbcb578..1cb69b8 100755 --- a/app/sbin/backupContainer +++ b/app/sbin/backupContainer @@ -1,42 +1,29 @@ #!/bin/bash exec >> /4server/data/log/backupContainer.log 2>&1 -echo "$(date '+%Y-%m-%d %H:%M') Backup container $1" -source /4server/sbin/helpers +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" -BIN_PATH="/4server/sbin" +BIN_PATH="$SBIN" -UUID="$1" +CONTAINER_ID="$1" -if [[ -z "$UUID" ]]; then - echo "Usage: $0 " - exit 1 -fi +[ -z "$CONTAINER_ID" ] && die "Usage: $0 " +load_container_config "$CONTAINER_ID" get_contract_info -# Extract the second part of UUID (split by "-") -SECOND_PART=$(echo "$UUID" | cut -d'-' -f2) +log "Backup container $CONTAINER_ID (SOFTWARE=$SOFTWARE)" -echo "Conatiner Type $UUID" - -# Decide which script to run -case "$SECOND_PART" in - 001) +# Decide which script to run based on SOFTWARE from container.env +case "$SOFTWARE" in + n8n) "$BIN_PATH/backup/N8N" ;; - 002) + ODOO_17|ODOO_18|ODOO_19) "$BIN_PATH/backup/ODOO" ;; - 003) - "$BIN_PATH/backup/ODOO" - ;; - 004) - "$BIN_PATH/backup/ODOO" - ;; - - *) - echo "Unknown UUID type: $SECOND_PART" - exit 2 + *) + die "Unknown SOFTWARE type '${SOFTWARE}' for container_id $CONTAINER_ID — run: initContainer $CONTAINER_ID " ;; esac diff --git a/app/sbin/checkBackup b/app/sbin/checkBackup index 7ed8571..dc5f369 100755 --- a/app/sbin/checkBackup +++ b/app/sbin/checkBackup @@ -6,14 +6,14 @@ from pathlib import Path BACKUP_ROOT = Path("/BACKUP") if len(sys.argv) < 2: - print("Usage: checkBackup ") + print("Usage: checkBackup ") sys.exit(1) -uuid = sys.argv[1] -directory = BACKUP_ROOT / uuid +container_id = sys.argv[1] +directory = BACKUP_ROOT / container_id if not directory.is_dir(): - print(f"ERROR: directory not found for UUID {uuid}") + print(f"ERROR: directory not found for container_id {container_id}") sys.exit(1) latest_date = None diff --git a/app/sbin/checkCalls b/app/sbin/checkCalls index 89adfca..9a68444 100755 --- a/app/sbin/checkCalls +++ b/app/sbin/checkCalls @@ -1,36 +1,43 @@ #!/bin/bash +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" + cd /4server/data/ while : do -for dir in ???-???-*; do +# nullglob: if no subdirectories exist the loop body is skipped entirely +shopt -s nullglob +for dir in */; do + dir="${dir%/}" + [ -z "$dir" ] && continue if [ -d "${dir}/cc" ]; then if [ -f "${dir}/cc/backup" ]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') BACKUP for: ${dir%/}" - /4server/sbin/backupContainer ${dir%/} 2 + log "BACKUP for: $dir" + "$SBIN/backupContainer" "$dir" rm "${dir}/cc/backup" fi if [ -f "${dir}/cc/restart" ]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') Restart for: ${dir%/}" - /4server/sbin/startContainer ${dir%/} + log "Restart for: $dir" + "$SBIN/startContainer" "$dir" rm "${dir}/cc/restart" fi if [ -f "${dir}/cc/restore" ]; then FILENAME=$(head -n 1 "${dir}/cc/restore") - echo "$(date '+%Y-%m-%d %H:%M:%S') Restore for: ${dir%/} - $FILENAME" - /4server/sbin/ODOO_19/restore ${dir%/} $FILENAME + log "Restore for: $dir - $FILENAME" + "$SBIN/odoo/restore" "$dir" "$FILENAME" rm "${dir}/cc/restore" fi fi if [ -d "${dir}/n8n" ]; then if [ -f "${dir}/n8n/database.sqlite-journal" ]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') Restart N8N (journal) for: ${dir%/}" - /4server/sbin/startContainer ${dir%/} + log "Restart N8N (journal) for: $dir" + "$SBIN/startContainer" "$dir" fi fi diff --git a/app/sbin/checkFilestore b/app/sbin/checkFilestore index 8f30883..8fdea7e 100755 --- a/app/sbin/checkFilestore +++ b/app/sbin/checkFilestore @@ -1,23 +1,23 @@ #!/bin/bash -# checkFilestore — compare container filestore vs host disk via md5 +# checkFilestore — compare container filestore vs host disk via md5 check_one() { -local UUID="$1" +local CONTAINER_ID="$1" -FILESTORE_HOST="/4server/data/$UUID/odoo/odoo-web-data/filestore/$UUID" +FILESTORE_HOST="/4server/data/$CONTAINER_ID/odoo/odoo-web-data/filestore/$CONTAINER_ID" [[ -d "$FILESTORE_HOST" ]] || { echo "ERROR: host filestore not found: $FILESTORE_HOST"; exit 2; } -doas docker ps --format "{{.Names}}" | grep -q "^${UUID}$" \ - || { echo "ERROR: container $UUID is not running"; exit 2; } +doas docker ps --format "{{.Names}}" | grep -q "^${CONTAINER_ID}$" \ + || { echo "ERROR: container $CONTAINER_ID is not running"; exit 2; } # Read data_dir from odoo.conf inside the container, fall back to default -DATA_DIR=$(doas docker exec "$UUID" sh -c \ +DATA_DIR=$(doas docker exec "$CONTAINER_ID" sh -c \ 'for f in /etc/odoo.conf /etc/odoo/odoo.conf /opt/odoo/etc/odoo.conf; do grep -E "^data_dir" "$f" 2>/dev/null | cut -d= -f2- | xargs && break done') DATA_DIR="${DATA_DIR:-/home/odoo/.local/share/Odoo}" -FILESTORE_CONTAINER="$DATA_DIR/filestore/$UUID" -MOUNT_HOST=$(doas docker inspect "$UUID" --format '{{json .Mounts}}' \ +FILESTORE_CONTAINER="$DATA_DIR/filestore/$CONTAINER_ID" +MOUNT_HOST=$(doas docker inspect "$CONTAINER_ID" --format '{{json .Mounts}}' \ | jq -r --arg dest "$DATA_DIR" '.[] | select(.Type=="bind") | select(.Destination==$dest) | .Source') echo "ODOO USES : $FILESTORE_CONTAINER" echo "Docker maps to : $DATA_DIR" @@ -25,7 +25,7 @@ echo "" echo "Docker mapped to : ${MOUNT_HOST:-}" echo "Hashing container filestore..." -MD5_CONTAINER=$(doas docker exec "$UUID" sh -c \ +MD5_CONTAINER=$(doas docker exec "$CONTAINER_ID" sh -c \ "find $FILESTORE_CONTAINER -type f | sort | xargs md5sum 2>/dev/null | awk '{print \$1}'" | md5sum | cut -d' ' -f1) echo "Hashing host filestore..." @@ -41,18 +41,18 @@ if [[ -n "${1-}" ]]; then check_one "$1" else for dir in /4server/data/00*/; do - UUID=$(basename "$dir") - result=$(check_one "$UUID" 2>&1) + CONTAINER_ID=$(basename "$dir") + result=$(check_one "$CONTAINER_ID" 2>&1) if echo "$result" | grep -q "OK: filestores match"; then - echo "$UUID OK" + echo "$CONTAINER_ID OK" elif echo "$result" | grep -q "not running"; then - echo "$UUID NOT RUNNING" + echo "$CONTAINER_ID NOT RUNNING" elif echo "$result" | grep -q "host filestore not found"; then - echo "$UUID NO FILESTORE" + echo "$CONTAINER_ID NO FILESTORE" elif echo "$result" | grep -q "cannot query DB"; then - echo "$UUID DB ERROR" + echo "$CONTAINER_ID DB ERROR" else - echo "$UUID MISMATCH" + echo "$CONTAINER_ID MISMATCH" fi done fi diff --git a/app/sbin/contractInfo b/app/sbin/contractInfo index 0913191..21edb67 100755 --- a/app/sbin/contractInfo +++ b/app/sbin/contractInfo @@ -1,13 +1,14 @@ export PATH=/4PROJECTS/bin:$PATH if [ ! -n "$1" ]; then - echo "Missing Parameters " + echo "Missing Parameters " exit 0 fi -UUID=$1 -echo "UUID: $UUID" +CONTAINER_ID=$1 +echo "CONTAINER_ID: $CONTAINER_ID" -source /4server/sbin/helpers +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" get_contract_info diff --git a/app/sbin/helpers b/app/sbin/helpers index 3e7b56a..98a9549 100755 --- a/app/sbin/helpers +++ b/app/sbin/helpers @@ -1,5 +1,48 @@ #!/bin/bash +# --------------------------------------------------------------------------- +# Logging — use these everywhere instead of bare echo +# --------------------------------------------------------------------------- +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*"; } +warn() { echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN] $*" >&2; } +die() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" >&2; exit 1; } + +# Resolve the absolute directory of a script. +# Always pass ${BASH_SOURCE[0]} — it holds the real path even when the +# script is invoked via PATH (unlike $0 which may be just a bare name). +# Usage in top-level scripts: SBIN="$(sbin_dir "${BASH_SOURCE[0]}")" +# Sub-scripts one level deep: SBIN_ROOT="$(dirname "$(sbin_dir "${BASH_SOURCE[0]}")")" +sbin_dir() { dirname "$(realpath "$1")"; } + +# --------------------------------------------------------------------------- +# load_container_config +# Sources /4server/data//config/container.env and exports: +# SOFTWARE — n8n | ODOO_17 | ODOO_18 | ODOO_19 +# DB_NAME — postgres database/user (may differ from CONTAINER_ID for migrations) +# --------------------------------------------------------------------------- +load_container_config() { + local container_id="${1:-$CONTAINER_ID}" + local cfg="/4server/data/$container_id/config/container.env" + if [ -f "$cfg" ]; then + while IFS= read -r line; do + case "$line" in + ''|'#'*) continue ;; + esac + local _k="${line%%=*}" + local _v="${line#*=}" + case "$_k" in + *[!A-Z_]*) continue ;; + esac + export "$_k=$_v" + done < "$cfg" + else + warn "no container.env found at $cfg" + fi +} + +# --------------------------------------------------------------------------- +# Postgres defaults (overridable via environment) +# --------------------------------------------------------------------------- POSTGRES_HOST="${POSTGRES_HOST:-beedb}" POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_ADMIN_USER="${POSTGRES_ADMIN_USER:-1gtT0sf8klB9lDbYZD9}" @@ -7,19 +50,19 @@ POSTGRES_ADMIN_PASSWORD="${POSTGRES_ADMIN_PASSWORD:-ZpSwWNafyy9GhY2gzHw}" DB_PATH="/4server/data/contracts.db" - +# --------------------------------------------------------------------------- +# get_contract_info +# Reads the contracts.db row for $CONTAINER_ID and exports all columns as env vars. +# Also parses SECRET_LIST and exports ODOO_DB_PASSWORD from the psql key. +# --------------------------------------------------------------------------- get_contract_info() { + log "get_contract_info $CONTAINER_ID" - echo "get_contract_info $UUID" - - # Single CTE: the table is scanned once and all projections come from it while IFS="=" read -r key value; do - if [ -n "$key" ]; then - export "$key=$value" - fi + [ -n "$key" ] && export "$key=$value" done < <(sqlite3 "$DB_PATH" " - WITH c AS (SELECT * FROM containers WHERE UUID='$UUID' LIMIT 1) - SELECT 'UUID=' || COALESCE(UUID, '') FROM c UNION ALL + WITH c AS (SELECT * FROM containers WHERE UUID='$CONTAINER_ID' LIMIT 1) + SELECT 'CONTAINER_ID=' || COALESCE(UUID, '') FROM c UNION ALL SELECT 'EXPIRES=' || COALESCE(expires, '') FROM c UNION ALL SELECT 'IMAGE=' || COALESCE(image, '') FROM c UNION ALL SELECT 'STATUS=' || COALESCE(status, '') FROM c UNION ALL @@ -33,18 +76,13 @@ get_contract_info() { SELECT 'DOMAINS_SLOTS=' || COALESCE(domains_slots, '') FROM c; ") - # Parse SECRET_LIST: backslash-separated key=value pairs (e.g. worex1=1\pswl=22) - # - printf avoids echo interpreting escape sequences - # - tr -d '\r\n' strips any embedded newlines the DB value may contain - # - tr '\\' '\n' turns each backslash separator into a real newline - # - %%=* / #*= split on the FIRST = only, so base64 values like tok=ab== are safe + # Parse SECRET_LIST: backslash-separated key=value pairs + # %%=* / #*= split on the FIRST = only, so base64 values like tok=ab== are safe if [ -n "$SECRET_LIST" ]; then while IFS= read -r pair || [ -n "$pair" ]; do _key="${pair%%=*}" _value="${pair#*=}" - if [ -n "$_key" ]; then - export "$_key=$_value" - fi + [ -n "$_key" ] && export "$_key=$_value" done < <(printf '%s' "$SECRET_LIST" | tr -d '\r\n' | tr '\\' '\n') fi export ODOO_DB_PASSWORD=$psql diff --git a/app/sbin/helpers.orig b/app/sbin/helpers.orig deleted file mode 100755 index 6f3712d..0000000 --- a/app/sbin/helpers.orig +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash - -POSTGRES_HOST="${POSTGRES_HOST:-beedb}" -POSTGRES_PORT="${POSTGRES_PORT:-5432}" -POSTGRES_ADMIN_USER="${POSTGRES_ADMIN_USER:-1gtT0sf8klB9lDbYZD9}" -POSTGRES_ADMIN_PASSWORD="${POSTGRES_ADMIN_PASSWORD:-ZpSwWNafyy9GhY2gzHw}" - -DB_PATH="/4server/data/contracts.db" - - -get_contract_info() { - - echo "get_contract_info $UUID" - - # Single CTE: the table is scanned once and all projections come from it - while IFS="=" read -r key value; do - if [ -n "$key" ]; then - export "$key=$value" - fi - done < <(sqlite3 "$DB_PATH" " - WITH c AS (SELECT * FROM containers WHERE UUID='$UUID' LIMIT 1) - SELECT 'UUID=' || COALESCE(UUID, '') FROM c UNION ALL - SELECT 'EXPIRES=' || COALESCE(expires, '') FROM c UNION ALL - SELECT 'IMAGE=' || COALESCE(image, '') FROM c UNION ALL - SELECT 'STATUS=' || COALESCE(status, '') FROM c UNION ALL - SELECT 'CREATED=' || COALESCE(created, '') FROM c UNION ALL - SELECT 'SECRET_LIST=' || COALESCE(secret_list, '') FROM c UNION ALL - SELECT 'CONTAINERDBID=' || COALESCE(id, '') FROM c UNION ALL - SELECT 'GIT=' || COALESCE(git, '') FROM c UNION ALL - SELECT 'BACKUP_SLOTS=' || COALESCE(backup_slots, '') FROM c UNION ALL - SELECT 'HDD=' || COALESCE(hdd, '') FROM c UNION ALL - SELECT 'WORKERS=' || COALESCE(workers, '') FROM c UNION ALL - SELECT 'DOMAINS_SLOTS=' || COALESCE(domains_slots, '') FROM c; - ") - - # Parse SECRET_LIST: backslash-separated key=value pairs (e.g. worex1=1\pswl=22) - # - printf avoids echo interpreting escape sequences - # - tr -d '\r\n' strips any embedded newlines the DB value may contain - # - tr '\\' '\n' turns each backslash separator into a real newline - # - %%=* / #*= split on the FIRST = only, so base64 values like tok=ab== are safe - if [ -n "$SECRET_LIST" ]; then - while IFS= read -r pair || [ -n "$pair" ]; do - _key="${pair%%=*}" - _value="${pair#*=}" - if [ -n "$_key" ]; then - export "$_key=$_value" - fi - done < <(printf '%s' "$SECRET_LIST" | tr -d '\r\n' | tr '\\' '\n') - fi -<<<<<<< HEAD - export ODOO_DB_PASSWORD=$psql -======= -done < <(sqlite3 "$DB_PATH" " - SELECT 'UUID=' || UUID FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'EMAIL=' || email FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'EXPIRES=' || expires FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'TAGS=' || replace(replace(tags, char(10), ''), char(13), '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'ENV=' || replace(replace(env, char(10), ''), char(13), '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'IMAGE=' || image FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'DOMAINS=' || domains FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'STATUS=' || status FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'CREATED=' || created FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'SECRET_LIST=' || COALESCE(secret_list, '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'CONTAINERDBID=' || id FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'BUMP=' || bump FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'GIT=' || COALESCE(git, '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'BACKUP_SLOTS=' || COALESCE(backup_slots, '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'HDD=' || COALESCE(hdd, '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'WORKERS=' || COALESCE(workers, '') FROM containers WHERE UUID='$UUID' - UNION ALL SELECT 'DOMAINS_SLOTS=' || COALESCE(domains_slots, '') FROM containers WHERE UUID='$UUID'; -") - - -# Parse ENV JSON into individual env vars -eval $(echo "$ENV" | jq -r 'to_entries | .[] | "export \(.key | ascii_upcase)=\(.value)"') - -# Parse SECRET_LIST (backslash-separated key=value pairs, e.g. worex1=1\pswl=22) -if [ -n "$SECRET_LIST" ]; then - while IFS='=' read -r key value; do - if [ -n "$key" ]; then - export "$key=$value" - fi - done < <(echo "$SECRET_LIST" | tr '\\' '\n') -fi - ->>>>>>> c583649 (hmm) -} diff --git a/app/sbin/migrate b/app/sbin/migrate index f75e4cf..74445a0 100755 --- a/app/sbin/migrate +++ b/app/sbin/migrate @@ -1,16 +1,126 @@ #!/bin/bash +# Migration script — run once per server after deploying updated code. +# Safe to re-run: each step checks whether it is needed before acting. -sqlite3 /4server/data/contracts.db </dev/null <<'EOF' || true ALTER TABLE containers ADD COLUMN env_list TEXT; -UPDATE containers -SET env_list = ( - SELECT group_concat(key || '=' || value, ', ') - FROM json_each(containers.env) -); -ALTER TABLE containers ADD COLUMN secret_list TEXT; -UPDATE containers -SET secret_list = ( - SELECT group_concat(key || '=' || value, ', ') - FROM json_each(containers.secret) -); EOF +sqlite3 "$DB" 2>/dev/null <<'EOF' || true +ALTER TABLE containers ADD COLUMN secret_list TEXT; +EOF +sqlite3 "$DB" 2>/dev/null <<'EOF' || true +ALTER TABLE containers ADD COLUMN utm_source text; +EOF +sqlite3 "$DB" 2>/dev/null <<'EOF' || true +ALTER TABLE containers ADD COLUMN utm_medium text; +EOF +sqlite3 "$DB" 2>/dev/null <<'EOF' || true +ALTER TABLE containers ADD COLUMN utm_campaign text; +EOF +sqlite3 "$DB" 2>/dev/null <<'EOF' || true +ALTER TABLE containers ADD COLUMN container_id CHAR(50); +EOF + +# ----------------------------------------------------------------------- +# Step 2 — Backfill container_id from UUID +# ----------------------------------------------------------------------- +echo "Step 2: backfilling container_id from UUID..." +sqlite3 "$DB" <<'EOF' +UPDATE containers +SET container_id = UUID +WHERE container_id IS NULL OR container_id = ''; +EOF + +# ----------------------------------------------------------------------- +# Step 3 — Backfill utm_* from old affiliate JSON (if column still exists) +# ----------------------------------------------------------------------- +if [ "$(has_column affiliate)" = "1" ]; then + echo "Step 3: backfilling utm fields from affiliate JSON..." + sqlite3 "$DB" <<'EOF' +UPDATE containers +SET utm_source = COALESCE(utm_source, json_extract(affiliate, '$.utm_source')), + utm_medium = COALESCE(utm_medium, json_extract(affiliate, '$.utm_medium')), + utm_campaign = COALESCE(utm_campaign, json_extract(affiliate, '$.utm_campaign')) +WHERE affiliate IS NOT NULL AND affiliate != ''; +EOF +else + echo "Step 3: affiliate column not present — skipping utm backfill." +fi + +# ----------------------------------------------------------------------- +# Step 4 — Remove affiliate column via full table rebuild. +# Also adds container_id UNIQUE constraint and locks in the canonical +# column order to match init_db(). +# Only runs if affiliate still exists OR container_id lacks its UNIQUE index. +# ----------------------------------------------------------------------- +NEEDS_REBUILD=0 +[ "$(has_column affiliate)" = "1" ] && NEEDS_REBUILD=1 + +# If affiliate is already gone, the rebuild already ran — no need to repeat +# (container_id UNIQUE is guaranteed by the rebuild itself) + +if [ "$NEEDS_REBUILD" = "1" ]; then + echo "Step 4: rebuilding table (remove affiliate, add container_id UNIQUE)..." + sqlite3 "$DB" <<'EOF' +BEGIN; + +CREATE TABLE containers_new ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + UUID CHAR(50) UNIQUE, + container_id CHAR(50) UNIQUE, + email CHAR(100), + expires DATE, + tags TEXT, + env TEXT, + image CHAR(50), + history TEXT, + comment TEXT, + domains TEXT, + status CHAR(20), + created DATE, + bump DATE, + secret TEXT, + contract TEXT, + git integer, + backup_slots integer, + hdd integer, + workers integer, + utm_source text, + utm_medium text, + utm_campaign text, + domains_slots integer, + secret_list TEXT, + env_list TEXT +); + +INSERT INTO containers_new + SELECT ID, UUID, container_id, email, expires, tags, env, image, history, + comment, domains, status, created, bump, secret, contract, git, + backup_slots, hdd, workers, utm_source, utm_medium, utm_campaign, + domains_slots, secret_list, env_list + FROM containers; + +DROP TABLE containers; +ALTER TABLE containers_new RENAME TO containers; + +COMMIT; +EOF + echo "Step 4: table rebuilt." +else + echo "Step 4: table already up to date — skipping rebuild." +fi + +echo "Migration complete." diff --git a/app/sbin/nuke/ODOO_19 b/app/sbin/nuke/ODOO_19 deleted file mode 100755 index 34fb8fc..0000000 --- a/app/sbin/nuke/ODOO_19 +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# Load functions -source /4server/sbin/ODOO_19/ODOO_19.lib -if [[ -z "$UUID" ]]; then - echo "Error: UUID not set. Aborting." - exit 1 -fi - -POSTGRES_HOST="${POSTGRES_HOST:-beedb}" -POSTGRES_PORT="${POSTGRES_PORT:-5432}" -POSTGRES_ADMIN_USER="${POSTGRES_ADMIN_USER:-1gtT0sf8klB9lDbYZD9}" -POSTGRES_ADMIN_PASSWORD="${POSTGRES_ADMIN_PASSWORD:-ZpSwWNafyy9GhY2gzHw}" -ODOO_DB_USER="${UUID}" -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') - -BASEURL="${BASEURL:-/4server/data/$UUID}" -BACKUPURL="/4backup/$UUID" - -doas docker stop "$UUID" -doas docker rm "$UUID" - -if [ -n "${UUID:-}" ]; then - echo "Removing directory: $BASEURL" - #doas rm -rf "$BASEURL" - echo "Removing backup directory $BACKUPURL" - #doas rm -rf $BACKUPURL -fi - - -echo "Dropping PostgreSQL database: $UUID (if exists)..." -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \ - -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres \ - -c "DROP DATABASE IF EXISTS \"$UUID\";" - -echo "Dropping PostgreSQL user: $ODOO_DB_USER (if exists)..." -PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \ - -h "$POSTGRES_HOST" -U "$POSTGRES_ADMIN_USER" -p "$POSTGRES_PORT" -d postgres < -# Example: ./start_by_uuid.sh abc-001-xxxx-xxxx-xxxx exec > /4server/data/log/nukeContainer.log 2>&1 -echo "$(date '+%Y-%m-%d %H:%M') Nuke container $1" -source /4server/sbin/helpers +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" -BIN_PATH="/4server/sbin" +BIN_PATH="$SBIN" +CONTAINER_ID="$1" -UUID="$1" - -if [[ -z "$UUID" ]]; then - echo "Usage: $0 " - exit 1 -fi +[ -z "$CONTAINER_ID" ] && die "Usage: $0 " +log "Nuke container $CONTAINER_ID" get_container_status() { - local uuid="$1" - # Get the container ID or name matching the UUID - CONTAINER_ID=$(docker ps -a --filter "name=$uuid" --format "{{.ID}}") - if [[ -z "$CONTAINER_ID" ]]; then + local container_id="$1" + # Get the Docker container ID or name matching the container_id + DOCKER_ID=$(docker ps -a --filter "name=$container_id" --format "{{.ID}}") + if [[ -z "$DOCKER_ID" ]]; then echo "not_found" return fi - STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_ID") + STATUS=$(docker inspect -f '{{.State.Status}}' "$DOCKER_ID") echo "$STATUS" } -# Check if container exists -STATUS=$(get_container_status "$UUID") -if [[ "$STATUS" == "running" ]]; then - echo "Container $UUID is still running. Aborting deletion." - exit 2 -fi +STATUS=$(get_container_status "$CONTAINER_ID") +[ "$STATUS" = "running" ] && die "Container $CONTAINER_ID is still running — stop it first" +load_container_config "$CONTAINER_ID" get_contract_info -# Extract the second part of UUID (split by "-") -SECOND_PART=$(echo "$UUID" | cut -d'-' -f2) - -# Decide which script to run -case "$SECOND_PART" in - 001) +case "$SOFTWARE" in + n8n) "$BIN_PATH/nuke/n8n" ;; - 002) - "$BIN_PATH/nuke/ODOO_19" + ODOO_17|ODOO_18|ODOO_19) + "$BIN_PATH/nuke/odoo" ;; *) - echo "Unknown UUID type: $SECOND_PART" - exit 2 + die "Unknown SOFTWARE type '${SOFTWARE}' for container_id $CONTAINER_ID — run: initContainer $CONTAINER_ID " ;; esac -#sqlite3 "/4server/data/contracts.db" < $(basename "${ARCHIVE}.gz")" + echo "$CONTAINER_ID rotated -> $(basename "${ARCHIVE}.gz")" # Delete rotated logs older than 4 weeks find "$DIR" -name 'odoo.log.*.gz' -mtime +$RETENTION_DAYS -exec rm -f {} \; \ - -exec echo "$UUID deleted: {}" \; + -exec echo "$CONTAINER_ID deleted: {}" \; done done diff --git a/app/sbin/start/ODOO_17 b/app/sbin/start/ODOO_17 deleted file mode 100755 index d5995bd..0000000 --- a/app/sbin/start/ODOO_17 +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash -echo "START ODOO 17" -# Load functions -source /4server/sbin/ODOO_19/ODOO_19.lib -source /4server/sbin/helpers -get_contract_info - -# Config variables -UUID="${UUID:-default}" -BRANCH="${BRANCH:-release}" - -ODOO_DB_USER="${UUID}" -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') - - -echo "ENV: $HDD $DOMAIN_SLOTS $BACKUP_SLOTS $CONTAINERDBID" -echo "DBID: $CONTAINERDBID" - -BASEURL="${BASEURL:-/4server/data/$UUID}" -DATA_DIR="$BASEURL/odoo/" -CUSTOM_DIR="$BASEURL/git/$UUID/custom/" -ENTERPRISE_DIR="$BASEURL/git/$UUID/enterprise/" -LOGS_DIR="$BASEURL/logs/" -CONFIG_DIR="$BASEURL/config/" -CC_DIR="$BASEURL/cc/" -BACKUP_DIR="/BACKUP/$UUID" -GIT_DIR="$BASEURL/git-server/" -INSTALL_DIR="$BASEURL/install/" -SSH_DIR="$BASEURL/.ssh/" -ETC_DIR="$BASEURL/etc/" - -SERVER_IP=$(ip -4 addr show eth0 | awk '/inet/ {print $2}' | cut -d/ -f1 | head -n1) - -LABEL_DOMAINS=("$UUID.odoo4projects.com") -if [ -f "$BASEURL/etc/domain" ]; then - while IFS= read -r domain || [[ -n $domain ]]; do - [ -z "$domain" ] && continue - LABEL_DOMAINS+=("$domain") - done < "$BASEURL/etc/domain" -else - echo "[DEBUG] No additional domain file found at $BASEURL/etc/domain" -fi - -RULE="" -for d in "${LABEL_DOMAINS[@]}"; do - RESOLVED_IP=$(nslookup "$d" 2>/dev/null | awk '/^Address: / { print $2 }' | head -n1 || true) - if [ -z "$RESOLVED_IP" ]; then - echo "[DEBUG] Could not resolve $d, skipping" - continue - fi - if [ "$RESOLVED_IP" = "$SERVER_IP" ]; then - RULE+=" || Host(\`$d\`)" - else - echo "[DEBUG] Skipping $d (does not match SERVER_IP $SERVER_IP)" - fi -done -RULE="${RULE# || }" -DOMAIN_LABEL="traefik.http.routers.$UUID.rule=$RULE" -echo "[DEBUG] Final Traefik label: $DOMAIN_LABEL" - -docker exec "$UUID" mkdir -p /var/lib/odoo/.local/share/Odoo/ -docker exec "$UUID" ln -s /home/odoo/.local/share/Odoo/filestore /var/lib/odoo/.local/share/Odoo/filestore - - -find "$BASEURL" -type d -exec chmod 777 {} \; -PORT=$((CONTAINERDBID + 2200)) -echo "PORT $PORT" -mkdir -p ${ETC_DIR} -echo "GITPATH ${ETC_DIR}gitpath" -touch ${ETC_DIR}gitpath - -mkdir -p ${GIT_DIR}keys -touch ${GIT_DIR}keys/id_rsa.pub - - -echo "git clone \"ssh://git@${UUID}.odoo4projects.com:${PORT}/git-server/repos/odoo.git\"" > "${ETC_DIR}/gitpath" - - -docker stop "$UUID" 2>/dev/null -docker rm "$UUID" 2>/dev/null - -EXTRA_DOCKER_PARAMETER="" - -docker run -d --name "$UUID" \ - --network 4server_4projects \ - --restart=always \ - $EXTRA_DOCKER_PARAMETER \ - -v "$DATA_DIR/odoo-web-data:/home/odoo/.local/share/Odoo" \ - -v "$CUSTOM_DIR:/mnt/addons/custom" \ - -v "$ENTERPRISE_DIR:/mnt/addons/enterprise" \ - -v "$LOGS_DIR:/mnt/logs" \ - -v "$CC_DIR:/mnt/cc" \ - -v "$BACKUP_DIR:/mnt/backup" \ - -v "$GIT_DIR:/git-server" \ - -v "$INSTALL_DIR:/mnt/install" \ - -v "$SSH_DIR:/etc/sshkey" \ - -v "$ETC_DIR:/mnt/etc" \ - -p "$PORT:22" \ - -e HOST="beedb" \ - -e USER="$ODOO_DB_USER" \ - -e PASSWORD="$ODOO_DB_PASSWORD" \ - -e UUID="$UUID" \ - -e HDD="$HDD" \ - -e DOMAIN_SLOTS="$DOMAIN_SLOTS" \ - -e BACKUP_SLOTS="$BACKUP_SLOTS" \ - -e WORKER="$WORKER" \ - -e GIT="$GIT" \ - --label "$DOMAIN_LABEL" \ - --label "traefik.http.services.$UUID.loadbalancer.server.port=8069" \ - --label "traefic.http.routers.$UUID.entrypoints=web, websecure" \ - --label "traefik.http.routers.$UUID.tls.certresolver=production" \ - --label "traefik.http.routers.$UUID.tls=true" \ - --label "traefik.http.routers.$UUID.service=$UUID" \ - docker.odoo4projects.com/4projects/odoo_17:$BRANCH - -docker exec "$UUID" mkdir -p /var/lib/odoo/.local/share/Odoo -docker exec "$UUID" rm -rf /var/lib/odoo/.local/share/Odoo/filestore -docker exec "$UUID" ln -s /home/odoo/.local/share/Odoo/filestore /var/lib/odoo/.local/share/Odoo/filestore - - -docker exec $UUID chown -R odoo:odoo /home/odoo/.local -docker exec $UUID chown -R odoo:odoo /var/lib/odoo/.local/share/Odoo -docker exec $UUID chown -R odoo:odoo /mnt/* -docker exec $UUID chown odoo:odoo /git-server/keys/id_rsa.pub - - -check_and_create_db diff --git a/app/sbin/start/ODOO_18 b/app/sbin/start/ODOO_18 deleted file mode 100755 index f76575b..0000000 --- a/app/sbin/start/ODOO_18 +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash - -# Load functions -source /4server/sbin/ODOO_19/ODOO_19.lib -source /4server/sbin/helpers -get_contract_info - -# Config variables -UUID="${UUID:-default}" -BRANCH="${BRANCH:-release}" - -ODOO_DB_USER="${UUID}" -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') - - -echo "ENV: $HDD $DOMAIN_SLOTS $BACKUP_SLOTS $CONTAINERDBID" -echo "DBID: $CONTAINERDBID" - -BASEURL="${BASEURL:-/4server/data/$UUID}" -DATA_DIR="$BASEURL/odoo/" -CUSTOM_DIR="$BASEURL/git/$UUID/custom/" -ENTERPRISE_DIR="$BASEURL/git/$UUID/enterprise/" -LOGS_DIR="$BASEURL/logs/" -CONFIG_DIR="$BASEURL/config/" -CC_DIR="$BASEURL/cc/" -BACKUP_DIR="/BACKUP/$UUID" -GIT_DIR="$BASEURL/git-server/" -INSTALL_DIR="$BASEURL/install/" -SSH_DIR="$BASEURL/.ssh/" -ETC_DIR="$BASEURL/etc/" - -SERVER_IP=$(ip -4 addr show eth0 | awk '/inet/ {print $2}' | cut -d/ -f1 | head -n1) - -LABEL_DOMAINS=("$UUID.odoo4projects.com") -if [ -f "$BASEURL/etc/domain" ]; then - while IFS= read -r domain || [[ -n $domain ]]; do - [ -z "$domain" ] && continue - LABEL_DOMAINS+=("$domain") - done < "$BASEURL/etc/domain" -else - echo "[DEBUG] No additional domain file found at $BASEURL/etc/domain" -fi - -RULE="" -for d in "${LABEL_DOMAINS[@]}"; do - RESOLVED_IP=$(nslookup "$d" 2>/dev/null | awk '/^Address: / { print $2 }' | head -n1 || true) - if [ -z "$RESOLVED_IP" ]; then - echo "[DEBUG] Could not resolve $d, skipping" - continue - fi - if [ "$RESOLVED_IP" = "$SERVER_IP" ]; then - RULE+=" || Host(\`$d\`)" - else - echo "[DEBUG] Skipping $d (does not match SERVER_IP $SERVER_IP)" - fi -done -RULE="${RULE# || }" -DOMAIN_LABEL="traefik.http.routers.$UUID.rule=$RULE" -echo "[DEBUG] Final Traefik label: $DOMAIN_LABEL" - -docker exec "$UUID" mkdir -p /var/lib/odoo/.local/share/Odoo/ -docker exec "$UUID" ln -s /home/odoo/.local/share/Odoo/filestore /var/lib/odoo/.local/share/Odoo/filestore - - -find "$BASEURL" -type d -exec chmod 777 {} \; -PORT=$((CONTAINERDBID + 2200)) -echo "PORT $PORT" -mkdir -p ${ETC_DIR} -echo "GITPATH ${ETC_DIR}gitpath" -touch ${ETC_DIR}gitpath - -mkdir -p ${GIT_DIR}keys -touch ${GIT_DIR}keys/id_rsa.pub - - -echo "git clone \"ssh://git@${UUID}.odoo4projects.com:${PORT}/git-server/repos/odoo.git\"" > "${ETC_DIR}/gitpath" - - -docker stop "$UUID" 2>/dev/null -docker rm "$UUID" 2>/dev/null - -EXTRA_DOCKER_PARAMETER="" - -docker run -d --name "$UUID" \ - --network 4server_4projects \ - --restart=always \ - $EXTRA_DOCKER_PARAMETER \ - -v "$DATA_DIR/odoo-web-data:/home/odoo/.local/share/Odoo" \ - -v "$CUSTOM_DIR:/mnt/addons/custom" \ - -v "$ENTERPRISE_DIR:/mnt/addons/enterprise" \ - -v "$LOGS_DIR:/mnt/logs" \ - -v "$CC_DIR:/mnt/cc" \ - -v "$BACKUP_DIR:/mnt/backup" \ - -v "$GIT_DIR:/git-server" \ - -v "$INSTALL_DIR:/mnt/install" \ - -v "$SSH_DIR:/etc/sshkey" \ - -v "$ETC_DIR:/mnt/etc" \ - -p "$PORT:22" \ - -e HOST="beedb" \ - -e USER="$ODOO_DB_USER" \ - -e PASSWORD="$ODOO_DB_PASSWORD" \ - -e UUID="$UUID" \ - -e HDD="$HDD" \ - -e DOMAIN_SLOTS="$DOMAIN_SLOTS" \ - -e BACKUP_SLOTS="$BACKUP_SLOTS" \ - -e WORKER="$WORKER" \ - -e GIT="$GIT" \ - --label "$DOMAIN_LABEL" \ - --label "traefik.http.services.$UUID.loadbalancer.server.port=8069" \ - --label "traefic.http.routers.$UUID.entrypoints=web, websecure" \ - --label "traefik.http.routers.$UUID.tls.certresolver=production" \ - --label "traefik.http.routers.$UUID.tls=true" \ - --label "traefik.http.routers.$UUID.service=$UUID" \ - docker.odoo4projects.com/4projects/odoo_18:$BRANCH - -docker exec "$UUID" mkdir -p /var/lib/odoo/.local/share/Odoo -docker exec "$UUID" rm -rf /var/lib/odoo/.local/share/Odoo/filestore -docker exec "$UUID" ln -s /home/odoo/.local/share/Odoo/filestore /var/lib/odoo/.local/share/Odoo/filestore - - -docker exec $UUID chown -R odoo:odoo /home/odoo/.local -docker exec $UUID chown -R odoo:odoo /var/lib/odoo/.local/share/Odoo -docker exec $UUID chown -R odoo:odoo /mnt/* -docker exec $UUID chown odoo:odoo /git-server/keys/id_rsa.pub - - -check_and_create_db diff --git a/app/sbin/start/ODOO_19 b/app/sbin/start/ODOO_19 deleted file mode 100755 index 33db1b7..0000000 --- a/app/sbin/start/ODOO_19 +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash - -# Load functions -source /4server/sbin/ODOO_19/ODOO_19.lib -source /4server/sbin/helpers -get_contract_info - -# Config variables -UUID="${UUID:-default}" -BRANCH="${BRANCH:-release}" - -ODOO_DB_USER="${UUID}" -#export ODOO_DB_PASSWORD=$(echo "$SECRET" | jq -r '.psql') -echo "***** $ODOO_DB_PASSWORD" - -echo "ENV: $HDD $DOMAIN_SLOTS $BACKUP_SLOTS $CONTAINERDBID" -echo "DBID: $CONTAINERDBID" - -BASEURL="${BASEURL:-/4server/data/$UUID}" -DATA_DIR="$BASEURL/odoo/" -CUSTOM_DIR="$BASEURL/git/$UUID/custom/" -ENTERPRISE_DIR="$BASEURL/git/$UUID/enterprise/" -LOGS_DIR="$BASEURL/logs/" -CONFIG_DIR="$BASEURL/config/" -CC_DIR="$BASEURL/cc/" -BACKUP_DIR="/BACKUP/$UUID" -GIT_DIR="$BASEURL/git-server/" -INSTALL_DIR="$BASEURL/install/" -SSH_DIR="$BASEURL/.ssh/" -ETC_DIR="$BASEURL/etc/" - -SERVER_IP=$(ip -4 addr show eth0 | awk '/inet/ {print $2}' | cut -d/ -f1 | head -n1) - -LABEL_DOMAINS=("$UUID.odoo4projects.com") -if [ -f "$BASEURL/etc/domain" ]; then - while IFS= read -r domain || [[ -n $domain ]]; do - [ -z "$domain" ] && continue - LABEL_DOMAINS+=("$domain") - done < "$BASEURL/etc/domain" -else - echo "[DEBUG] No additional domain file found at $BASEURL/etc/domain" -fi - -RULE="" -for d in "${LABEL_DOMAINS[@]}"; do - RESOLVED_IP=$(nslookup "$d" 2>/dev/null | awk '/^Address: / { print $2 }' | head -n1 || true) - if [ -z "$RESOLVED_IP" ]; then - echo "[DEBUG] Could not resolve $d, skipping" - continue - fi - if [ "$RESOLVED_IP" = "$SERVER_IP" ]; then - RULE+=" || Host(\`$d\`)" - else - echo "[DEBUG] Skipping $d (does not match SERVER_IP $SERVER_IP)" - fi -done -RULE="${RULE# || }" -DOMAIN_LABEL="traefik.http.routers.$UUID.rule=$RULE" -echo "[DEBUG] Final Traefik label: $DOMAIN_LABEL" - - - -find "$BASEURL" -type d -exec chmod 777 {} \; -PORT=$((CONTAINERDBID + 2200)) -echo "PORT $PORT" -mkdir -p ${ETC_DIR} -chmod 777 ${ETC_DIR} -echo "GITPATH ${ETC_DIR}gitpath" -touch ${ETC_DIR}gitpath - -mkdir -p ${BACKUP_DIR} -chmod 777 ${BACKUP_DIR} - -mkdir -p ${GIT_DIR}keys -chmod 777 ${GIT_DIR} -touch ${GIT_DIR}keys/id_rsa.pub - - -echo "git clone \"ssh://git@${UUID}.odoo4projects.com:${PORT}/git-server/repos/odoo.git\"" > "${ETC_DIR}/gitpath" - - -docker stop "$UUID" 2>/dev/null -docker rm "$UUID" 2>/dev/null - -EXTRA_DOCKER_PARAMETER="" - -docker run -d --name "$UUID" \ - --network 4server_4projects \ - --restart=always \ - $EXTRA_DOCKER_PARAMETER \ - -v "$DATA_DIR/odoo-web-data:/home/odoo/.local/share/Odoo" \ - -v "$CUSTOM_DIR:/mnt/addons/custom" \ - -v "$ENTERPRISE_DIR:/mnt/addons/enterprise" \ - -v "$LOGS_DIR:/mnt/logs" \ - -v "$CC_DIR:/mnt/cc" \ - -v "$BACKUP_DIR:/mnt/backup" \ - -v "$GIT_DIR:/git-server" \ - -v "$INSTALL_DIR:/mnt/install" \ - -v "$SSH_DIR:/etc/sshkey" \ - -v "$ETC_DIR:/mnt/etc" \ - -p "$PORT:22" \ - -e HOST="beedb" \ - -e USER="$ODOO_DB_USER" \ - -e PASSWORD="$ODOO_DB_PASSWORD" \ - -e UUID="$UUID" \ - -e HDD="$HDD" \ - -e DOMAIN_SLOTS="$DOMAIN_SLOTS" \ - -e BACKUP_SLOTS="$BACKUP_SLOTS" \ - -e WORKER="$WORKER" \ - -e GIT="$GIT" \ - --label "$DOMAIN_LABEL" \ - --label "traefik.http.services.$UUID.loadbalancer.server.port=8069" \ - --label "traefik.http.routers.$UUID.entrypoints=web, websecure" \ - --label "traefik.http.routers.$UUID.tls.certresolver=production" \ - --label "traefik.http.routers.$UUID.tls=true" \ - --label "traefik.http.routers.$UUID.service=$UUID" \ - docker.odoo4projects.com/4projects/odoo_19:$BRANCH - - -docker exec $UUID chown -R odoo:odoo /home/odoo/.local -docker exec $UUID chown -R odoo:odoo /mnt/* -docker exec $UUID chown -R git:git /git-server -chmod 777 /4server/data/$UUID/cc - -check_and_create_db diff --git a/app/sbin/start/n8n b/app/sbin/start/n8n index acec4b1..5530830 100755 --- a/app/sbin/start/n8n +++ b/app/sbin/start/n8n @@ -1,29 +1,29 @@ #!/usr/bin/env bash -#--label "traefik.http.routers.${UUID}.middlewares=cors-headers@file" \ -echo "Start N8N container ${UUID}" +#--label "traefik.http.routers.${CONTAINER_ID}.middlewares=cors-headers@file" \ +echo "Start N8N container ${CONTAINER_ID}" # Get the hostname of the machine HOSTNAME=$(hostname) -mkdir -p /4server/data/${UUID}/n8n -mkdir -p /4server/data/${UUID}/data -mkdir -p /4server/data/${UUID}/backup +mkdir -p /4server/data/${CONTAINER_ID}/n8n +mkdir -p /4server/data/${CONTAINER_ID}/data +mkdir -p /4server/data/${CONTAINER_ID}/backup -chmod 777 /4server/data/${UUID}/n8n -chmod 777 /4server/data/${UUID}/data -chmod 777 /4server/data/${UUID}/backup +chmod 777 /4server/data/${CONTAINER_ID}/n8n +chmod 777 /4server/data/${CONTAINER_ID}/data +chmod 777 /4server/data/${CONTAINER_ID}/backup # Stop the container if it exists -if docker ps -a --format '{{.Names}}' | grep -q "^${UUID}$"; then - echo "$(date '+%Y-%m-%d %H:%M') - stopping existing container $UUID" - docker stop "$UUID" - docker rm "$UUID" +if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_ID}$"; then + echo "$(date '+%Y-%m-%d %H:%M') - stopping existing container $CONTAINER_ID" + docker stop "$CONTAINER_ID" + docker rm "$CONTAINER_ID" fi docker run -d \ - --name "$UUID" \ + --name "$CONTAINER_ID" \ -p 5678 \ --cap-add=SYS_ADMIN \ --security-opt seccomp=unconfined \ @@ -35,7 +35,7 @@ docker run -d \ -e N8N_SMTP_PASS="Airbus12N@N" \ -e N8N_SMTP_SENDER="n8n " \ -e N8N_SMTP_SSL=false \ - -e N8N_HOST="${UUID}.odoo4projects.com" \ + -e N8N_HOST="${CONTAINER_ID}.odoo4projects.com" \ -e N8N_PORT=5678 \ -e N8N_PROTOCOL=https \ -e N8N_FORMDATA_FILE_SIZE_MAX=3000 \ @@ -43,19 +43,19 @@ docker run -d \ -e NODE_ENV=production \ -e EXECUTIONS_PRUNE=true \ -e EXECUTIONS_PRUNE_MAX_AGE=48 \ - -e WEBHOOK_URL="https://${UUID}.odoo4projects.com/" \ + -e WEBHOOK_URL="https://${CONTAINER_ID}.odoo4projects.com/" \ -e GENERIC_TIMEZONE="UTC-3" \ -e N8N_BLOCK_ENV_ACCESS_AND_PROCESS_INFORMATION=true \ -e N8N_CUSTOM_EXTENSIONS="/usr/local/share/n8n/custom" \ - -v "/4server/data/${UUID}/n8n:/home/node/.n8n" \ - -v "/4server/data/${UUID}/data:/data" \ - -v "/4server/data/${UUID}/backup:/backup" \ + -v "/4server/data/${CONTAINER_ID}/n8n:/home/node/.n8n" \ + -v "/4server/data/${CONTAINER_ID}/data:/data" \ + -v "/4server/data/${CONTAINER_ID}/backup:/backup" \ --label "traefik.enable=true" \ - --label "traefik.http.routers.${UUID}.rule=Host(\`${UUID}.odoo4projects.com\`)" \ - --label "traefik.http.routers.${UUID}.entrypoints=web,websecure" \ - --label "traefik.http.routers.${UUID}.tls=true" \ - --label "traefik.http.routers.${UUID}.tls.certresolver=production" \ - --label "traefik.http.services.${UUID}.loadbalancer.server.port=5678" \ + --label "traefik.http.routers.${CONTAINER_ID}.rule=Host(\`${CONTAINER_ID}.odoo4projects.com\`)" \ + --label "traefik.http.routers.${CONTAINER_ID}.entrypoints=web,websecure" \ + --label "traefik.http.routers.${CONTAINER_ID}.tls=true" \ + --label "traefik.http.routers.${CONTAINER_ID}.tls.certresolver=production" \ + --label "traefik.http.services.${CONTAINER_ID}.loadbalancer.server.port=5678" \ --network 4server_4projects \ docker.odoo4projects.com/4projects/n8n:release diff --git a/app/sbin/startContainer b/app/sbin/startContainer index a2687fb..8aa5cd7 100755 --- a/app/sbin/startContainer +++ b/app/sbin/startContainer @@ -1,65 +1,46 @@ #!/bin/bash -# Usage: ./start_by_uuid.sh -# Example: ./start_by_uuid.sh abc-001-xxxx-xxxx-xxxx exec > /4server/data/log/startContainer.log 2>&1 -echo "$(date '+%Y-%m-%d %H:%M:%S') Start container $1" -source /4server/sbin/helpers +_self="$(realpath "${BASH_SOURCE[0]:-$0}")"; SBIN="$(dirname "$_self")" +source "$SBIN/helpers" -BIN_PATH="/4server/sbin" +BIN_PATH="$SBIN" +CONTAINER_ID="$1" -UUID="$1" - -if [[ -z "$UUID" ]]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') Usage: $0 " - exit 1 -fi - -if [[ ! "$UUID" =~ ^[0-9a-fA-F-]+$ ]]; then - echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: Invalid UUID format: $UUID" - exit 1 -fi +[ -z "$CONTAINER_ID" ] && die "Usage: $0 " +log "Start container $CONTAINER_ID" -DOMAIN_FILE="/4server/data/$UUID/etc/domain" +DOMAIN_FILE="/4server/data/$CONTAINER_ID/etc/domain" DB_FILE="/4server/data/contracts.db" if [ -f "$DOMAIN_FILE" ]; then DOMAINS=$(paste -sd "," "$DOMAIN_FILE") # Escape single quotes for SQLite ('' is the standard SQLite escape for ') DOMAINS_SAFE="${DOMAINS//\'/\'\'}" - UUID_SAFE="${UUID//\'/\'\'}" - sqlite3 "$DB_FILE" "UPDATE containers SET domains='$DOMAINS_SAFE' WHERE UUID='$UUID_SAFE';" + CONTAINER_ID_SAFE="${CONTAINER_ID//\'/\'\'}" + sqlite3 "$DB_FILE" "UPDATE containers SET domains='$DOMAINS_SAFE' WHERE UUID='$CONTAINER_ID_SAFE';" fi +load_container_config "$CONTAINER_ID" get_contract_info -# Extract the second part of UUID (split by "-") -SECOND_PART=$(echo "$UUID" | cut -d'-' -f2) - -# Decide which script to run -case "$SECOND_PART" in - 001) +# Decide which script to run based on SOFTWARE from container.env +case "$SOFTWARE" in + n8n) "$BIN_PATH/start/n8n" ;; - 002) - "$BIN_PATH/start/ODOO_18" + ODOO_17) + IMAGE_TAG=odoo_17 "$BIN_PATH/start/odoo" ;; - - - 003) - "$BIN_PATH/start/ODOO_19" + ODOO_18) + IMAGE_TAG=odoo_18 "$BIN_PATH/start/odoo" ;; - - 004) - "$BIN_PATH/start/ODOO_17" + ODOO_19) + IMAGE_TAG=odoo_19 "$BIN_PATH/start/odoo" ;; - - - *) - echo "$(date '+%Y-%m-%d %H:%M:%S') Unknown UUID type: $SECOND_PART" - exit 2 + die "Unknown SOFTWARE type '${SOFTWARE}' for container_id $CONTAINER_ID — run: initContainer $CONTAINER_ID " ;; esac diff --git a/app/templates/traefik.yaml b/app/templates/traefik.yaml index 7519104..3495377 100644 --- a/app/templates/traefik.yaml +++ b/app/templates/traefik.yaml @@ -87,7 +87,7 @@ http: routers: api-router: - rule: "Host(`{{HOSTNAME}}.odoo4projects.com`)" + rule: "Host(`{{HOSTNAME}}.odoo4projects.com`) || Host(`api.{{HOSTNAME}}.odoo4projects.com`)" service: api-service entryPoints: - websecure diff --git a/app/update b/app/update index f29d46e..2b541ad 100755 --- a/app/update +++ b/app/update @@ -31,7 +31,7 @@ rex doas pip install --root-user-action ignore --break-system-packages --no-cach ### own bins echo "Running prsync ./sbin" -prsync -h "/app/host_vars/hosts" -avz ./sbin/ /4server/sbin/ +prsync -h "/app/host_vars/hosts" -avz ./sbin/ /usr/sbin/ ### POSTGRESS rex mkdir -p /4server/data/postgres/etc diff --git a/app/update_sbin b/app/update_sbin index ae0b36c..60e54df 100755 --- a/app/update_sbin +++ b/app/update_sbin @@ -1,7 +1,7 @@ #!/bin/bash echo "Running prsync ./sbin" -prsync -h "/app/host_vars/hosts" -avz ./sbin/ /4server/sbin/ +prsync -h "/app/host_vars/hosts" -avz ./sbin/ /usr/sbin/ template templates/.profile /home/4server/.profile template templates/postgresql.conf /4server/data/postgres/data/postgresql.conf diff --git a/app/vault/host_vars.img b/app/vault/host_vars.img index 08baff20f848f74ac1f38e0fff13c969d2292649..09a58da83b35da4b288ceb49ea9ec97e686480dc 100644 GIT binary patch delta 22246 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 delta 22246 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