diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d601319e7..a40e25a04 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - bash install.sh + bash scripts/install.sh poetry install --all-extras - name: Set up Node.js diff --git a/run.sh b/run.sh index 0ae456555..caada4ce6 100755 --- a/run.sh +++ b/run.sh @@ -4,22 +4,14 @@ script="neurons/validator.py" autoRunLoc=$(readlink -f "$0") proc_name="s1_validator_main_process" -update_proc_name="check_updates" +update_proc_name="auto_updater" args=() version_location="./prompting/__init__.py" version="__version__" old_args=$@ -# Check if pm2 is installed -if ! command -v pm2 &> /dev/null -then - echo "pm2 could not be found. Please run the install.sh script first." - exit 1 -fi - -# Uninstall uvloop -poetry run pip uninstall -y uvloop +bash scripts/install.sh # Loop through all command line arguments while [[ $# -gt 0 ]]; do @@ -84,11 +76,15 @@ echo "module.exports = { args: ['run', 'python', '$script', $joined_args] }, { - name: 'check_updates', - script: './scripts/check_updates.sh', + name: 'auto_updater', + script: './scripts/autoupdater.sh', interpreter: '/bin/bash', min_uptime: '5m', - max_restarts: '5' + max_restarts: '5', + env: { + 'UPDATE_CHECK_INTERVAL': '300', + 'GIT_BRANCH': 'main' + } } ] };" > app.config.js diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/autoupdater.sh b/scripts/autoupdater.sh new file mode 100644 index 000000000..adb5e5c9d --- /dev/null +++ b/scripts/autoupdater.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Configuration with defaults +readonly INTERVAL=${UPDATE_CHECK_INTERVAL:-300} +readonly REMOTE_BRANCH=${GIT_BRANCH:-"main"} +readonly PYPROJECT_PATH="./pyproject.toml" +readonly LOG_FILE="autoupdate.log" +readonly MAX_RETRIES=3 +readonly RETRY_DELAY=30 + +# Logging with ISO 8601 timestamps +log() { + local level=$1 + shift + printf '[%s] [%-5s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" | tee -a "$LOG_FILE" +} +compare_semver() { + local v1=() v2=() + IFS='.' read -r -a v1 <<< "$1" + IFS='.' read -r -a v2 <<< "$2" + + # Normalize length (e.g., handle "1.2" as "1.2.0") + for i in 0 1 2; do + v1[i]=${v1[i]:-0} + v2[i]=${v2[i]:-0} + done + + # Compare each section of MAJOR, MINOR, PATCH + for i in 0 1 2; do + if (( v1[i] > v2[i] )); then + return 1 # v1 is greater + elif (( v1[i] < v2[i] )); then + return 2 # v2 is greater + fi + # if equal, continue to next + done + + return 0 # versions are the same +} +get_version() { + local file=$1 + local version + + if [[ ! -f "$file" ]]; then + log ERROR "File not found: $file" + return 1 + fi + + version=$(awk -F'"' '/^version *= *"/ {print $2}' "$file") + if [[ -z "$version" ]]; then + log ERROR "Version not found in $file" + return 1 + fi + + echo "$version" +} + +# Retry mechanism for git operations +retry() { + local cmd=$1 + local attempt=1 + + while [[ $attempt -le $MAX_RETRIES ]]; do + if eval "$cmd"; then + return 0 + fi + + log WARN "Command failed (attempt $attempt/$MAX_RETRIES): $cmd" + + if [[ $attempt -lt $MAX_RETRIES ]]; then + sleep "$RETRY_DELAY" + fi + + ((attempt++)) + done + + return 1 +} + +# Backup local changes if any exist +backup_changes() { + if ! git diff --quiet; then + local backup_branch="backup/$(date -u '+%Y%m%d_%H%M%S')" + log WARN "Creating backup branch: $backup_branch" + git stash && git stash branch "$backup_branch" + fi +} + +# Main update check function +check_for_updates() { + local local_version remote_version + local_version=$(get_version "$PYPROJECT_PATH") || return 1 + + # Fetch remote updates + if ! retry "git fetch origin $REMOTE_BRANCH"; then + log ERROR "Failed to fetch from remote" + return 1 + fi + + # Get remote version from pyproject.toml in the remote branch + remote_version=$(git show "origin/$REMOTE_BRANCH:$PYPROJECT_PATH" \ + | awk -F'"' '/^version *= *"/ {print $2}') || { + log ERROR "Failed to get remote version" + return 1 + } + + # Semantic version comparison + compare_semver "$local_version" "$remote_version" + local cmp=$? + + if [[ $cmp -eq 2 ]]; then + # Return code 2 => remote_version is greater (update available) + log INFO "Update available: $local_version → $remote_version" + return 0 + elif [[ $cmp -eq 0 ]]; then + # Versions are the same + log INFO "Already up to date ($local_version)" + return 1 + else + # Return code 1 => local is actually ahead or equal in some custom scenario + # If you only want to *update if remote is strictly greater*, treat this case as no update. + # Or you could log something else if local is ahead. + log INFO "Local version ($local_version) is the same or newer than remote ($remote_version)" + return 1 + fi +} + +# Update and restart application +update_and_restart() { + backup_changes + + if ! retry "git pull origin $REMOTE_BRANCH"; then + log ERROR "Failed to pull changes" + return 1 + fi + + if [[ -x "./run.sh" ]]; then + log INFO "Update successful, restarting application..." + exec ./run.sh + else + log ERROR "run.sh not found or not executable" + return 1 + fi +} + +# Validate git repository +validate_environment() { + if ! git rev-parse --git-dir > /dev/null 2>&1; then + log ERROR "Not in a git repository" + exit 1 + fi +} + +main() { + validate_environment + log INFO "Starting auto-updater (interval: ${INTERVAL}s, branch: $REMOTE_BRANCH)" + + while true; do + if check_for_updates; then + update_and_restart + fi + sleep "$INTERVAL" + done +} + +main diff --git a/scripts/check_updates.sh b/scripts/check_updates.sh deleted file mode 100644 index 415e6f4b8..000000000 --- a/scripts/check_updates.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/bin/bash - -# Initialize variables -version_location="./prompting/__init__.py" -version="__version__" -proc_name="s1_validator_main_process" -old_args=$@ -branch=$(git branch --show-current) # get current branch. - -# Function definitions -version_less_than_or_equal() { - [ "$1" = "$(echo -e "$1\n$2" | sort -V | head -n1)" ] -} - -version_less_than() { - [ "$1" = "$2" ] && return 1 || version_less_than_or_equal $1 $2 -} - -get_version_difference() { - local tag1="$1" - local tag2="$2" - local version1=$(echo "$tag1" | sed 's/v//') - local version2=$(echo "$tag2" | sed 's/v//') - IFS='.' read -ra version1_arr <<< "$version1" - IFS='.' read -ra version2_arr <<< "$version2" - local diff=0 - for i in "${!version1_arr[@]}"; do - local num1=${version1_arr[$i]} - local num2=${version2_arr[$i]} - if (( num1 > num2 )); then - diff=$((diff + num1 - num2)) - elif (( num1 < num2 )); then - diff=$((diff + num2 - num1)) - fi - done - strip_quotes $diff -} - -read_version_value() { - while IFS= read -r line; do - if [[ "$line" == *"$version"* ]]; then - local value=$(echo "$line" | awk -F '=' '{print $2}' | tr -d ' ') - strip_quotes $value - return 0 - fi - done < "$version_location" - echo "" -} - -check_package_installed() { - local package_name="$1" - local os_name=$(uname -s) - if [[ "$os_name" == "Linux" ]]; then - if dpkg-query -W -f='${Status}' "$package_name" 2>/dev/null | grep -q "installed"; then - return 1 - else - return 0 - fi - elif [[ "$os_name" == "Darwin" ]]; then - if brew list --formula | grep -q "^$package_name$"; then - return 1 - else - return 0 - fi - else - echo "Unknown operating system" - return 0 - fi -} - -check_variable_value_on_github() { - local repo="$1" - local file_path="$2" - local variable_name="$3" - local url="https://api.github.com/repos/$repo/contents/$file_path" - local response=$(curl -s "$url") - if [[ $response =~ "message" ]]; then - echo "Error: Failed to retrieve file contents from GitHub." - return 1 - fi - local content=$(echo "$response" | tr -d '\n' | jq -r '.content') - if [[ "$content" == "null" ]]; then - echo "File '$file_path' not found in the repository." - return 1 - fi - local decoded_content=$(echo "$content" | base64 --decode) - local variable_value=$(echo "$decoded_content" | grep "$variable_name" | awk -F '=' '{print $2}' | tr -d ' ') - if [[ -z "$variable_value" ]]; then - echo "Variable '$variable_name' not found in the file '$file_path'." - return 1 - fi - strip_quotes $variable_value -} - -strip_quotes() { - local input="$1" - local stripped="${input#\"}" - stripped="${stripped%\"}" - echo "$stripped" -} - -update_script() { - if git pull origin $branch; then - echo "New version published. Updating the local copy." - poetry install - pm2 del auto_run_validator - echo "Restarting PM2 process" - pm2 restart $proc_name - current_version=$(read_version_value) - echo "Restarting script..." - ./$(basename $0) $old_args && exit - else - echo "**Will not update**" - echo "It appears you have made changes on your local copy. Please stash your changes using git stash." - fi -} - -check_for_update() { - if [ -d "./.git" ]; then - latest_version=$(check_variable_value_on_github "macrocosm-os/prompting" "prompting/__init__.py" "__version__ ") - current_version=$(read_version_value) - if version_less_than $current_version $latest_version; then - echo "latest version $latest_version" - echo "current version $current_version" - echo "current validator version: $current_version" - echo "latest validator version: $latest_version" - update_script - else - echo "**Skipping update **" - echo "$current_version is the same as or more than $latest_version. You are likely running locally." - fi - else - echo "The installation does not appear to be done through Git. Please install from source at https://github.com/macrocosm-os/prompting and rerun this script." - fi -} - -main() { - check_package_installed "jq" - if [ "$?" -eq 1 ]; then - while true; do - check_for_update - sleep 1800 - done - else - echo "Missing package 'jq'. Please install it for your system first." - fi -} - -# Run the main function -main diff --git a/install.sh b/scripts/install.sh similarity index 100% rename from install.sh rename to scripts/install.sh diff --git a/scripts/test_autoupdater.sh b/scripts/test_autoupdater.sh new file mode 100755 index 000000000..9b3373ff2 --- /dev/null +++ b/scripts/test_autoupdater.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test environment setup +TEST_DIR="updater_test" +INTERVAL=5 + +# Create test environment +setup_test_environment() { + echo "Setting up test environment..." + rm -rf "$TEST_DIR" + mkdir -p "$TEST_DIR" + + if [[ ! -f "$TEST_DIR/autoupdater.sh" ]]; then + cp "scripts/autoupdater.sh" "$TEST_DIR/" || cp "autoupdater.sh" "$TEST_DIR/" + fi + chmod +x "$TEST_DIR/autoupdater.sh" + + cd "$TEST_DIR" + + # Initialize main repo + git init --initial-branch=main + git config user.email "test@example.com" + git config user.name "Test User" + + cat > pyproject.toml << EOF +[project] +name = "test-project" +version = "1.0.0" +EOF + + # Create dummy run.sh + cat > run.sh << EOF +#!/bin/bash +echo "Running version \$(grep '^version' pyproject.toml | cut -d'"' -f2)" +EOF + chmod +x run.sh + + # Create initial commit + git add . + git commit -m "Initial commit" + git branch -M main + + # Create a clone to simulate remote updates + cd .. + git clone "$TEST_DIR" "${TEST_DIR}_remote" + cd "${TEST_DIR}_remote" + + # Update remote version + sed -i.bak 's/version = "1.0.0"/version = "1.1.0"/' pyproject.toml + git commit -am "Bump version to 1.1.0" + cd "../$TEST_DIR" + git remote add origin "../${TEST_DIR}_remote" +} + +# Clean up test environment +cleanup() { + echo "Cleaning up..." + cd .. + rm -rf "$TEST_DIR" "${TEST_DIR}_remote" +} + +# Run the test +run_test() { + echo "Starting auto-updater test..." + + # Start the auto-updater in background + UPDATE_CHECK_INTERVAL=$INTERVAL ./autoupdater.sh & + UPDATER_PID=$! + + # Wait for a few intervals + echo "Waiting for auto-updater to detect changes..." + sleep $((INTERVAL * 2)) + + # Kill the auto-updater + kill $UPDATER_PID || true + wait $UPDATER_PID 2>/dev/null || true + + # Check results + LOCAL_VERSION=$(grep '^version' pyproject.toml | cut -d'"' -f2) + if [ "$LOCAL_VERSION" = "1.1.0" ]; then + echo "✅ Test passed! Version was updated successfully." + else + echo "❌ Test failed! Version was not updated (still $LOCAL_VERSION)" + fi +} + +# Main test execution +main() { + setup_test_environment + run_test + cleanup +} + +main diff --git a/tests/scripts/test_autoupdater.py b/tests/scripts/test_autoupdater.py new file mode 100644 index 000000000..a20127690 --- /dev/null +++ b/tests/scripts/test_autoupdater.py @@ -0,0 +1,53 @@ +import os +import shutil +import subprocess +import time + +import pytest + + +def test_autoupdater_script(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(os.path.dirname(current_dir)) + autoupdater_path = os.path.join(project_root, "scripts", "autoupdater.sh") + test_script_path = os.path.join(project_root, "scripts", "test_autoupdater.sh") + + assert os.path.exists(test_script_path), f"Test script not found at {test_script_path}" + assert os.path.exists(autoupdater_path), f"Autoupdater script not found at {autoupdater_path}" + + test_dir = os.path.join(project_root, "updater_test") + os.makedirs(test_dir, exist_ok=True) + + try: + subprocess.run(["git", "config", "--global", "user.name", "AutoUpdater"], check=True) + subprocess.run(["git", "config", "--global", "user.email", "autoupdater@example.com"], check=True) + + remote_dir = os.path.join(project_root, "updater_test_remote") + if os.path.exists(remote_dir): + shutil.rmtree(remote_dir) + + shutil.copy2(autoupdater_path, os.path.join(test_dir, "autoupdater.sh")) + process = subprocess.Popen( + [test_script_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=project_root, + ) + + timeout = 60 + start_time = time.time() + while process.poll() is None: + if time.time() - start_time > timeout: + process.kill() + pytest.fail("Autoupdater testing script timed out after 60 seconds") + time.sleep(1) + + stdout, stderr = process.communicate() + + assert process.returncode == 0, f"Script failed with error: {stderr}" + assert "✅ Test passed!" in stdout, "The test did not pass as expected." + + finally: + if os.path.exists(test_dir): + shutil.rmtree(test_dir)