├── src ├── __init__.py ├── version.py ├── health_check.py ├── config_diff.py ├── web_gui.py ├── web_gui_dev.py └── unraid_config_guardian.py ├── assets ├── demo_home.png ├── demo_containers.png ├── favicon_io │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── unraid_guardian_logo.png └── unraid_guardian_logo_template.png ├── config └── config.yml ├── templates ├── error.html ├── containers.html ├── base.html ├── dashboard.html └── backups.html ├── requirements-dev.txt ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci-cd.yml ├── .dockerignore ├── requirements.txt ├── .env.example ├── .githooks └── pre-push ├── .gitignore ├── docker ├── refresh-templates.sh └── entrypoint.sh ├── .pre-commit-config.yaml ├── pyproject.toml ├── LICENSE ├── Dockerfile ├── docker-compose.dev.yml ├── docker-compose.yml ├── unraid-template.xml ├── tests └── test_unraid_config_guardian.py ├── docs ├── troubleshooting.md ├── CONTRIBUTING.md └── DEPLOYMENT.md ├── README.md └── Makefile /src/__init__.py: -------------------------------------------------------------------------------- 1 | # Unraid Config Guardian package 2 | -------------------------------------------------------------------------------- /src/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Version information for Unraid Config Guardian 3 | """ 4 | 5 | __version__ = "1.5.2" 6 | -------------------------------------------------------------------------------- /assets/demo_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/demo_home.png -------------------------------------------------------------------------------- /assets/demo_containers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/demo_containers.png -------------------------------------------------------------------------------- /assets/favicon_io/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/favicon.ico -------------------------------------------------------------------------------- /assets/unraid_guardian_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/unraid_guardian_logo.png -------------------------------------------------------------------------------- /assets/favicon_io/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon_io/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon_io/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/unraid_guardian_logo_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/unraid_guardian_logo_template.png -------------------------------------------------------------------------------- /assets/favicon_io/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/favicon_io/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/HEAD/assets/favicon_io/android-chrome-512x512.png -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | guardian: 2 | backup: 3 | mask_passwords: true 4 | include_system_info: true 5 | output_location: /output 6 | 7 | notifications: 8 | webhook_url: 9 | email: 10 | 11 | debug: true 12 | -------------------------------------------------------------------------------- /assets/favicon_io/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 2 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Error - Unraid Config Guardian{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 | 10 |

Error

11 |

{{ error }}

12 |
13 | 14 | Return to Dashboard 15 | 16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Development dependencies for Unraid Config Guardian 2 | -r requirements.txt 3 | 4 | # Testing framework 5 | pytest>=7.4.0 6 | pytest-cov>=4.1.0 7 | pytest-mock>=3.11.0 8 | pytest-asyncio>=0.21.0 9 | 10 | # Code quality and formatting 11 | black>=23.7.0 12 | flake8>=6.0.0 13 | mypy>=1.5.0 14 | isort>=5.12.0 15 | 16 | # Type stubs 17 | types-requests>=2.31.0 18 | types-PyYAML>=6.0.12 19 | types-toml>=0.10.8 20 | 21 | # Documentation 22 | sphinx>=7.1.0 23 | sphinx-rtd-theme>=1.3.0 24 | 25 | # Development utilities 26 | pre-commit>=3.3.0 27 | tox>=4.6.0 28 | 29 | # Linting and formatting (versions should match .pre-commit-config.yaml) 30 | black==23.9.1 31 | isort==5.12.0 32 | flake8==6.0.0 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: stephondoestech 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # Python 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | *.so 10 | .Python 11 | env/ 12 | venv/ 13 | .env 14 | .venv 15 | pip-log.txt 16 | pip-delete-this-directory.txt 17 | .tox/ 18 | .coverage 19 | .coverage.* 20 | .cache 21 | nosetests.xml 22 | coverage.xml 23 | *.cover 24 | *.log 25 | .pytest_cache/ 26 | 27 | # Development 28 | .vscode/ 29 | .idea/ 30 | *.swp 31 | *.swo 32 | *~ 33 | 34 | # Documentation 35 | docs/ 36 | *.md 37 | !README.md 38 | 39 | # Testing 40 | tests/ 41 | .pytest_cache/ 42 | 43 | # Build artifacts 44 | build/ 45 | dist/ 46 | *.egg-info/ 47 | 48 | # Local configuration 49 | config.yml 50 | *.env 51 | !.env.example 52 | 53 | # Output directories 54 | output/ 55 | backup/ 56 | logs/ 57 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core dependencies for Unraid Config Guardian 2 | docker>=6.1.0 3 | requests>=2.31.0 4 | pyyaml>=6.0.1 5 | jinja2>=3.1.2 6 | 7 | # Configuration and environment handling 8 | python-dotenv>=1.0.0 9 | toml>=0.10.2 10 | 11 | # CLI and logging 12 | click>=8.1.0 13 | colorama>=0.4.6 14 | rich>=13.0.0 15 | 16 | # Data processing and validation 17 | pydantic>=2.0.0 18 | jsonschema>=4.19.0 19 | 20 | # File operations and compression 21 | pathlib2>=2.3.7; python_version < "3.4" 22 | 23 | # Web interface components (for system-report.html generation) 24 | beautifulsoup4>=4.12.0 25 | markdown>=3.5.0 26 | 27 | # Scheduling and cron support 28 | croniter>=1.4.0 29 | 30 | # Security and masking 31 | cryptography>=41.0.0 32 | 33 | # Web GUI 34 | fastapi>=0.104.0 35 | uvicorn[standard]>=0.24.0 36 | jinja2>=3.1.2 37 | python-multipart>=0.0.6 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Unraid Config Guardian Environment Variables 2 | 3 | # User/Group mapping for Unraid 4 | PUID=99 # nobody user ID 5 | PGID=100 # users group ID 6 | 7 | # Timezone 8 | TZ=America/New_York # Adjust to your timezone 9 | 10 | # Backup scheduling (cron format) 11 | SCHEDULE=0 2 * * 0 # Weekly backup on Sunday at 2 AM 12 | 13 | # Output directory inside container 14 | OUTPUT_DIR=/output 15 | 16 | # Security settings 17 | MASK_PASSWORDS=true # Mask sensitive environment variables 18 | INCLUDE_SYSTEM_INFO=true # Include system information in backup 19 | 20 | # Debug mode 21 | DEBUG=false # Enable debug logging 22 | 23 | # Optional: Notification settings 24 | # WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url 25 | # EMAIL_NOTIFICATIONS=admin@yourdomain.com 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: stephondoestech 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment:** 27 | - Unraid Version: [e.g. 6.12.4] 28 | - Container Version: [e.g. latest, v1.0.0] 29 | - Browser: [e.g. chrome, safari] 30 | 31 | **Container Logs** 32 | ``` 33 | Paste relevant container logs here 34 | ``` 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.githooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Pre-push hook to auto-format code before pushing 4 | # This prevents linting-related commits by fixing formatting issues automatically 5 | 6 | echo "🔍 Running pre-push formatting check..." 7 | 8 | # Check if we have uncommitted changes 9 | if ! git diff --quiet || ! git diff --cached --quiet; then 10 | echo "⚠️ You have uncommitted changes. Please commit or stash them first." 11 | exit 1 12 | fi 13 | 14 | # Run format-fix to clean up any formatting issues 15 | echo "🔧 Auto-fixing formatting issues..." 16 | make format-fix 17 | 18 | # Check if format-fix made any changes 19 | if ! git diff --quiet; then 20 | echo "✅ Formatting issues were fixed automatically" 21 | echo "📝 Staging and committing formatting changes..." 22 | 23 | # Stage the formatting changes 24 | git add -A 25 | 26 | # Commit the formatting fixes 27 | git commit -m "Auto-fix formatting before push 28 | 29 | echo "✅ Formatting fixes committed successfully" 30 | else 31 | echo "✅ No formatting issues found" 32 | fi 33 | 34 | echo "🚀 Proceeding with push..." 35 | exit 0 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Unraid Config Guardian - .gitignore 2 | 3 | # ================================ 4 | # Generated Documentation - Don't commit actual server configs 5 | # ================================ 6 | unraid-backup/ 7 | backup/ 8 | output/ 9 | unraid-config.json 10 | docker-compose.generated.yml 11 | restore.sh 12 | 13 | # ================================ 14 | # Python 15 | # ================================ 16 | __pycache__/ 17 | *.py[cod] 18 | *.so 19 | venv/ 20 | .venv/ 21 | env/ 22 | .env 23 | *.egg-info/ 24 | dist/ 25 | build/ 26 | 27 | # Testing 28 | .pytest_cache/ 29 | .coverage 30 | htmlcov/ 31 | *.log 32 | 33 | # ================================ 34 | # Development 35 | # ================================ 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *~ 40 | .DS_Store 41 | 42 | # ================================ 43 | # Docker 44 | # ================================ 45 | docker-compose.override.yml 46 | 47 | # ================================ 48 | # Sensitive Data - Never commit 49 | # ================================ 50 | *password* 51 | *secret* 52 | *token* 53 | *api_key* 54 | 55 | # But allow example files 56 | !*.example 57 | !*.template 58 | CLAUDE.md 59 | AGENTS.md 60 | -------------------------------------------------------------------------------- /docker/refresh-templates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Template refresh script - runs with elevated privileges to update cached templates 3 | 4 | TEMPLATES_SOURCE="/boot/config/plugins/dockerMan/templates-user" 5 | TEMPLATES_CACHE="/output/cached-templates" 6 | 7 | echo "🔄 Refreshing cached templates..." 8 | 9 | # Check if source directory exists 10 | if [ ! -d "$TEMPLATES_SOURCE" ]; then 11 | echo "❌ Template source directory not found: $TEMPLATES_SOURCE" 12 | exit 1 13 | fi 14 | 15 | # Create cache directory 16 | mkdir -p "$TEMPLATES_CACHE" 17 | 18 | # Remove old cached templates 19 | rm -f "$TEMPLATES_CACHE"/*.xml 2>/dev/null 20 | 21 | # Copy current templates 22 | if [ "$(ls -A "$TEMPLATES_SOURCE"/*.xml 2>/dev/null)" ]; then 23 | cp "$TEMPLATES_SOURCE"/*.xml "$TEMPLATES_CACHE"/ 2>/dev/null 24 | template_count=$(ls -1 "$TEMPLATES_CACHE"/*.xml 2>/dev/null | wc -l) 25 | echo "✅ Refreshed $template_count XML templates" 26 | 27 | # Ensure proper ownership for the user who will read them 28 | if [ -n "$PUID" ] && [ -n "$PGID" ]; then 29 | chown -R "$PUID:$PGID" "$TEMPLATES_CACHE" 2>/dev/null || true 30 | fi 31 | else 32 | echo "ℹ️ No XML templates found to refresh" 33 | fi 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Black - Python code formatter 3 | - repo: https://github.com/psf/black 4 | rev: 23.9.1 5 | hooks: 6 | - id: black 7 | args: [--line-length=88] 8 | language_version: python3 9 | 10 | # isort - Import sorting 11 | - repo: https://github.com/pycqa/isort 12 | rev: 5.12.0 13 | hooks: 14 | - id: isort 15 | args: ["--profile", "black", "--line-length=88"] 16 | 17 | # Flake8 - Linting 18 | - repo: https://github.com/pycqa/flake8 19 | rev: 6.0.0 20 | hooks: 21 | - id: flake8 22 | args: [ 23 | "--max-line-length=100", 24 | "--extend-ignore=E203,W503,E231,E221,E272,E201,E202,E702,E271,E501", 25 | "--exclude=.git,__pycache__,venv,.venv,build,dist" 26 | ] 27 | 28 | # Remove trailing whitespace 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v4.4.0 31 | hooks: 32 | - id: trailing-whitespace 33 | - id: end-of-file-fixer 34 | - id: check-yaml 35 | - id: check-json 36 | - id: check-merge-conflict 37 | - id: check-added-large-files 38 | - id: debug-statements 39 | 40 | # MyPy - Type checking 41 | - repo: https://github.com/pre-commit/mirrors-mypy 42 | rev: v1.5.1 43 | hooks: 44 | - id: mypy 45 | args: [--ignore-missing-imports, --no-strict-optional] 46 | additional_dependencies: [types-requests, types-PyYAML] 47 | exclude: ^(tests/|docs/) 48 | 49 | # Dockerfile linting 50 | - repo: https://github.com/hadolint/hadolint 51 | rev: v2.12.0 52 | hooks: 53 | - id: hadolint-docker 54 | args: [--ignore, DL3008, --ignore, DL3009] 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py311'] 4 | include = '\.pyi?$' 5 | extend-exclude = ''' 6 | /( 7 | # directories 8 | \.eggs 9 | | \.git 10 | | \.hg 11 | | \.mypy_cache 12 | | \.tox 13 | | \.venv 14 | | _build 15 | | buck-out 16 | | build 17 | | dist 18 | )/ 19 | ''' 20 | 21 | [tool.isort] 22 | profile = "black" 23 | line_length = 88 24 | multi_line_output = 3 25 | include_trailing_comma = true 26 | force_grid_wrap = 0 27 | use_parentheses = true 28 | ensure_newline_before_comments = true 29 | 30 | [tool.flake8] 31 | max-line-length = 88 32 | extend-ignore = ["E203", "W503"] 33 | exclude = [".git", "__pycache__", "venv", ".venv", "build", "dist"] 34 | 35 | [tool.mypy] 36 | python_version = "3.11" 37 | ignore_missing_imports = true 38 | no_strict_optional = true 39 | warn_return_any = true 40 | warn_unused_configs = true 41 | disallow_untyped_defs = false 42 | exclude = ["tests/", "docs/"] 43 | 44 | [tool.pytest.ini_options] 45 | minversion = "7.0" 46 | addopts = "-v --tb=short --strict-markers" 47 | testpaths = ["tests"] 48 | python_files = ["test_*.py", "*_test.py"] 49 | python_classes = ["Test*"] 50 | python_functions = ["test_*"] 51 | 52 | [tool.coverage.run] 53 | source = ["src"] 54 | omit = [ 55 | "tests/*", 56 | "venv/*", 57 | ".venv/*", 58 | "*/site-packages/*" 59 | ] 60 | 61 | [tool.coverage.report] 62 | exclude_lines = [ 63 | "pragma: no cover", 64 | "def __repr__", 65 | "if self.debug:", 66 | "if settings.DEBUG", 67 | "raise AssertionError", 68 | "raise NotImplementedError", 69 | "if 0:", 70 | "if __name__ == .__main__.:", 71 | "class .*\\bProtocol\\):", 72 | "@(abc\\.)?abstractmethod", 73 | ] 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Stephon Parker / StephonDoesTech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | Additional Terms for Unraid Integration: 26 | 27 | This software is designed to work with Unraid OS and integrates with various 28 | Unraid Community Applications. The software does not modify Unraid OS itself 29 | and respects all Unraid licensing terms. 30 | 31 | Docker container inspection and configuration generation are performed using 32 | publicly available Docker APIs and do not interfere with normal Unraid operation. 33 | 34 | Users are responsible for ensuring their use of this software complies with 35 | their Unraid license terms and any applicable container software licenses. 36 | 37 | --- 38 | 39 | Third-Party Acknowledgments: 40 | 41 | This software may integrate with or depend upon: 42 | - Docker Engine and Docker APIs (Apache 2.0 License) 43 | - Node.js runtime (MIT License) 44 | - Various NPM packages (see package.json for individual licenses) 45 | - docker-autocompose project (MIT License) 46 | - Unraid Community Applications (various licenses) 47 | 48 | All third-party software retains its original licensing terms. 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | LABEL maintainer="Stephon Parker " 4 | LABEL description="Unraid Config Guardian - Disaster recovery documentation for Unraid servers" 5 | 6 | # Set environment variables 7 | ENV PYTHONUNBUFFERED=1 \ 8 | PYTHONDONTWRITEBYTECODE=1 \ 9 | PIP_NO_CACHE_DIR=1 \ 10 | PIP_DISABLE_PIP_VERSION_CHECK=1 11 | 12 | # Create non-root user for security 13 | RUN groupadd -g 1000 guardian && \ 14 | useradd -u 1000 -g guardian -s /bin/bash -m guardian 15 | 16 | # Install system dependencies 17 | RUN apt-get update && apt-get install -y --no-install-recommends \ 18 | curl \ 19 | cron \ 20 | gosu \ 21 | sudo \ 22 | && rm -rf /var/lib/apt/lists/* 23 | 24 | # Set working directory 25 | WORKDIR /app 26 | 27 | # Copy requirements first for better caching 28 | COPY requirements.txt . 29 | RUN pip install --no-cache-dir -r requirements.txt 30 | 31 | # Copy application code 32 | COPY src/ ./src/ 33 | COPY templates/ ./templates/ 34 | 35 | # Create directories for configuration and output 36 | RUN mkdir -p /config /output && \ 37 | chown -R guardian:guardian /app /config /output 38 | 39 | # Copy entrypoint and helper scripts 40 | COPY docker/entrypoint.sh /entrypoint.sh 41 | COPY docker/refresh-templates.sh /usr/local/bin/refresh-templates.sh 42 | RUN chmod +x /entrypoint.sh /usr/local/bin/refresh-templates.sh 43 | 44 | # Make refresh script setuid root so it can run with elevated privileges 45 | # Also configure sudo as backup method 46 | RUN chmod 4755 /usr/local/bin/refresh-templates.sh \ 47 | && echo "guardian ALL=(root) NOPASSWD: /usr/local/bin/refresh-templates.sh" > /etc/sudoers.d/guardian-templates \ 48 | && chmod 440 /etc/sudoers.d/guardian-templates 49 | 50 | # Note: Start as root to allow entrypoint.sh to handle PUID/PGID switching 51 | # The entrypoint will switch to the appropriate user (guardian or PUID/PGID) 52 | 53 | # Health check 54 | HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ 55 | CMD cd /app && python src/health_check.py || exit 1 56 | 57 | # Expose port for web interface (if implemented) 58 | EXPOSE 7842 59 | 60 | # Set entrypoint 61 | ENTRYPOINT ["/entrypoint.sh"] 62 | CMD ["python", "src/web_gui.py"] 63 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose for local development 2 | 3 | services: 4 | unraid-config-guardian: 5 | build: . 6 | container_name: unraid-config-guardian 7 | restart: unless-stopped 8 | 9 | environment: 10 | # Development settings - use current user's IDs 11 | - PUID=${PUID:-1000} # Use your local user ID 12 | - PGID=${PGID:-1000} # Use your local group ID 13 | - TZ=America/New_York # Adjust to your timezone 14 | 15 | # Docker connection (uses socket proxy for security) 16 | - DOCKER_HOST=tcp://docker-socket-proxy:2375 17 | 18 | # Backup configuration 19 | - SCHEDULE=0 2 * * 0 # Weekly Sunday at 2 AM 20 | - BACKUP_LOCATION=/output 21 | - MASK_PASSWORDS=true 22 | - INCLUDE_SYSTEM_INFO=true 23 | - DEBUG=true 24 | 25 | # Optional: Notification settings 26 | # - WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url 27 | # - EMAIL_NOTIFICATIONS=admin@yourdomain.com 28 | 29 | volumes: 30 | # Local development paths 31 | - ./config:/config 32 | - ./output:/output 33 | 34 | # Docker socket proxy connection (requires docker-socket-proxy container) 35 | 36 | # For local development - these paths won't exist on non-Unraid systems 37 | # Comment out or adjust as needed 38 | # - /boot:/boot:ro 39 | 40 | networks: 41 | - unraid-guardian 42 | 43 | # Web interface port 44 | ports: 45 | - "7842:7842" 46 | 47 | # Security settings 48 | security_opt: 49 | - no-new-privileges:true 50 | 51 | # Resource limits 52 | mem_limit: 1g 53 | cpus: 0.5 54 | 55 | # Health check 56 | healthcheck: 57 | test: ["CMD", "python", "src/health_check.py"] 58 | interval: 30s 59 | timeout: 10s 60 | retries: 3 61 | start_period: 40s 62 | 63 | # Docker Socket Proxy for secure Docker access 64 | docker-socket-proxy: 65 | image: tecnativa/docker-socket-proxy:latest 66 | container_name: docker-socket-proxy 67 | restart: unless-stopped 68 | environment: 69 | - CONTAINERS=1 # Allow container inspection 70 | - IMAGES=1 # Allow image inspection 71 | - INFO=1 # Allow system info 72 | - NETWORKS=1 # Allow network inspection 73 | - VOLUMES=1 # Allow volume inspection 74 | - POST=0 # Disable container creation/modification 75 | volumes: 76 | - /var/run/docker.sock:/var/run/docker.sock:ro 77 | networks: 78 | - unraid-guardian 79 | 80 | networks: 81 | unraid-guardian: 82 | driver: bridge 83 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | unraid-config-guardian: 5 | build: . 6 | container_name: unraid-config-guardian 7 | restart: unless-stopped 8 | 9 | environment: 10 | # Unraid user/group mapping 11 | - PUID=99 # nobody user 12 | - PGID=100 # users group 13 | - TZ=America/New_York # Adjust to your timezone 14 | 15 | # Docker connection (uses socket proxy for security) 16 | - DOCKER_HOST=tcp://docker-socket-proxy:2375 17 | 18 | # Backup configuration 19 | - SCHEDULE=0 2 * * 0 # Weekly Sunday at 2 AM 20 | - BACKUP_LOCATION=/output 21 | - MASK_PASSWORDS=true 22 | - INCLUDE_SYSTEM_INFO=true 23 | - DEBUG=false 24 | 25 | # Optional: Notification settings 26 | # - WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url 27 | # - EMAIL_NOTIFICATIONS=admin@yourdomain.com 28 | 29 | volumes: 30 | # Application configuration 31 | - /mnt/user/appdata/unraid-config-guardian:/config 32 | 33 | # Output directory for generated documentation 34 | - /mnt/user/backups/unraid-docs:/output 35 | 36 | # Docker socket proxy connection (requires docker-socket-proxy container) 37 | 38 | # Unraid flash drive configuration (read-only) 39 | - /boot:/boot:ro 40 | 41 | # Optional: Additional system paths for comprehensive backup 42 | # - /mnt/user:/mnt/user:ro 43 | # - /etc/unraid:/etc/unraid:ro 44 | 45 | networks: 46 | - unraid-guardian 47 | 48 | # Web interface port 49 | ports: 50 | - "7842:7842" 51 | 52 | # Security settings 53 | security_opt: 54 | - no-new-privileges:true 55 | 56 | # Resource limits 57 | mem_limit: 1g 58 | cpus: 0.5 59 | 60 | # Health check 61 | healthcheck: 62 | test: ["CMD", "python", "src/health_check.py"] 63 | interval: 30s 64 | timeout: 10s 65 | retries: 3 66 | start_period: 40s 67 | 68 | # Docker Socket Proxy for secure Docker access 69 | docker-socket-proxy: 70 | image: tecnativa/docker-socket-proxy:latest 71 | container_name: docker-socket-proxy 72 | restart: unless-stopped 73 | environment: 74 | - CONTAINERS=1 # Allow container inspection 75 | - IMAGES=1 # Allow image inspection 76 | - INFO=1 # Allow system info 77 | - NETWORKS=1 # Allow network inspection 78 | - VOLUMES=1 # Allow volume inspection 79 | - POST=0 # Disable container creation/modification 80 | volumes: 81 | - /var/run/docker.sock:/var/run/docker.sock:ro 82 | networks: 83 | - unraid-guardian 84 | 85 | networks: 86 | unraid-guardian: 87 | driver: bridge 88 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*' ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | 14 | env: 15 | DOCKER_IMAGE: stephondoestech/unraid-config-guardian 16 | 17 | jobs: 18 | test: 19 | name: Test & Lint 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: '3.11' 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -r requirements.txt 35 | pip install -r requirements-dev.txt 36 | 37 | - name: Run tests 38 | run: pytest tests/ -v 39 | 40 | - name: Run linting 41 | run: | 42 | black --check src/ tests/ 43 | flake8 src/ tests/ --max-line-length=100 --extend-ignore=E203,W503 44 | 45 | docker-deploy: 46 | name: Deploy to Docker Hub 47 | runs-on: ubuntu-latest 48 | needs: test 49 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 50 | 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v4 54 | 55 | - name: Set up Docker Buildx 56 | uses: docker/setup-buildx-action@v3 57 | 58 | - name: Login to Docker Hub 59 | uses: docker/login-action@v3 60 | with: 61 | username: ${{ secrets.DOCKER_USERNAME }} 62 | password: ${{ secrets.DOCKER_TOKEN }} 63 | 64 | - name: Extract metadata 65 | id: meta 66 | uses: docker/metadata-action@v5 67 | with: 68 | images: ${{ env.DOCKER_IMAGE }} 69 | tags: | 70 | type=ref,event=branch 71 | type=semver,pattern={{version}} 72 | type=raw,value=latest,enable={{is_default_branch}} 73 | 74 | - name: Build and push Docker image 75 | uses: docker/build-push-action@v5 76 | with: 77 | context: . 78 | push: true 79 | tags: ${{ steps.meta.outputs.tags }} 80 | labels: ${{ steps.meta.outputs.labels }} 81 | cache-from: type=gha 82 | cache-to: type=gha,mode=max 83 | 84 | - name: Create GitHub Release 85 | if: startsWith(github.ref, 'refs/tags/v') 86 | uses: softprops/action-gh-release@v1 87 | with: 88 | name: Release ${{ github.ref_name }} 89 | body: | 90 | ## Docker Images 91 | - `${{ env.DOCKER_IMAGE }}:${{ github.ref_name }}` 92 | - `${{ env.DOCKER_IMAGE }}:latest` 93 | 94 | ## Usage 95 | ```bash 96 | docker pull ${{ env.DOCKER_IMAGE }}:${{ github.ref_name }} 97 | ``` 98 | draft: false 99 | prerelease: false 100 | -------------------------------------------------------------------------------- /unraid-template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | UnraidConfigGuardian 4 | stephondoestech/unraid-config-guardian:latest 5 | https://hub.docker.com/r/stephondoestech/unraid-config-guardian 6 | bridge 7 | 8 | sh 9 | false 10 | https://forums.unraid.net/topic/193147-support-stephondoestech-unraidconfigguardian/ 11 | https://github.com/stephondoestech/unraid-config-guardian 12 | Automatically generate comprehensive disaster recovery documentation for your Unraid setup. Creates docker-compose files, restoration scripts, and system backups for complete server recreation in under 30 minutes. 13 | 14 | Unraid Config Guardian automatically generates comprehensive disaster recovery documentation for your Unraid setup. 15 | 16 | Features: 17 | - Complete server recreation in under 30 minutes 18 | - Docker-compose generation from running containers 19 | - Automated restoration scripts with security-conscious data masking 20 | - Web-based interface for easy management 21 | - Scheduled automated backups 22 | 23 | ***Install the dockersocket container from the apps page. You will need to add a variable to the container called IMAGES and give it a value of 1. The docker socket proxy will return a 403 without this.*** 24 | 25 | Access the web interface at http://your-unraid-ip:7842 26 | Backup: 27 | http://[IP]:[PORT:7842] 28 | 29 | https://raw.githubusercontent.com/stephondoestech/unraid-config-guardian/main/assets/unraid_guardian_logo_template.png 30 | 31 | 32 | 33 | 1756410488 34 | 35 | 36 | ***THIS REQUIRES THE dockersocket APP FROM THE APP STORE TO RUN*** 37 | 7842 38 | /mnt/user/appdata/unraid-config-guardian 39 | /mnt/user/backups/unraid-docs 40 | /boot 41 | 99 42 | 100 43 | America/New_York 44 | 0 2 * * 0 45 | true 46 | true 47 | tcp://dockersocket-ip:2375 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/test_unraid_config_guardian.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple tests for Unraid Config Guardian 4 | """ 5 | 6 | import sys 7 | from pathlib import Path 8 | from unittest.mock import Mock, patch 9 | 10 | import pytest 11 | 12 | # Import the module to test 13 | sys.path.insert(0, "src") 14 | import unraid_config_guardian as guardian # noqa: E402 15 | 16 | 17 | def test_get_containers(): 18 | """Test container information extraction.""" 19 | with patch("docker.from_env") as mock_docker: 20 | # Mock container 21 | mock_container = Mock() 22 | mock_container.name = "test-container" 23 | mock_container.image.tags = ["nginx:latest"] 24 | mock_container.status = "running" 25 | mock_container.attrs = { 26 | "NetworkSettings": {"Ports": {"80/tcp": [{"HostPort": "8080"}]}}, 27 | "Mounts": [ 28 | { 29 | "Type": "bind", 30 | "Source": "/host/path", 31 | "Destination": "/container/path", 32 | } 33 | ], 34 | "Config": {"Env": ["TEST_VAR=test_value", "SECRET_PASSWORD=hidden"]}, 35 | } 36 | 37 | mock_client = Mock() 38 | mock_client.containers.list.return_value = [mock_container] 39 | mock_docker.return_value = mock_client 40 | 41 | containers = guardian.get_containers() 42 | 43 | assert len(containers) == 1 44 | container = containers[0] 45 | assert container["name"] == "test-container" 46 | assert container["image"] == "nginx:latest" 47 | assert container["status"] == "running" 48 | assert "8080:80/tcp" in container["ports"] 49 | assert "/host/path:/container/path" in container["volumes"] 50 | assert container["environment"]["SECRET_PASSWORD"] == "***MASKED***" 51 | 52 | 53 | def test_generate_compose(): 54 | """Test docker-compose generation.""" 55 | containers = [ 56 | { 57 | "name": "test-app", 58 | "image": "nginx:latest", 59 | "status": "running", 60 | "ports": ["8080:80/tcp"], 61 | "volumes": ["/host:/container"], 62 | "environment": {"ENV": "prod"}, 63 | } 64 | ] 65 | 66 | compose = guardian.generate_compose(containers) 67 | 68 | assert compose["version"] == "3.8" 69 | assert "test-app" in compose["services"] 70 | service = compose["services"]["test-app"] 71 | assert service["image"] == "nginx:latest" 72 | assert service["restart"] == "unless-stopped" 73 | assert "8080:80/tcp" in service["ports"] 74 | 75 | 76 | @patch("subprocess.run") 77 | @patch("builtins.open") 78 | def test_get_system_info(mock_open, mock_subprocess): 79 | """Test system information gathering.""" 80 | mock_subprocess.return_value.stdout = "unraid-server" 81 | 82 | # Mock file contexts with proper enter/exit 83 | mock_ident_file = Mock() 84 | mock_ident_file.__enter__ = Mock(return_value=mock_ident_file) 85 | mock_ident_file.__exit__ = Mock(return_value=None) 86 | mock_ident_file.__iter__ = Mock(return_value=iter(['NAME="TestServer"\n'])) 87 | 88 | mock_changes_file = Mock() 89 | mock_changes_file.__enter__ = Mock(return_value=mock_changes_file) 90 | mock_changes_file.__exit__ = Mock(return_value=None) 91 | mock_changes_file.readline.return_value = "# Version 7.1.4 2025-06-18" 92 | 93 | def open_side_effect(filename, *args, **kwargs): 94 | if "/boot/config/ident.cfg" in filename: 95 | return mock_ident_file 96 | elif "/boot/changes.txt" in filename: 97 | return mock_changes_file 98 | return Mock() 99 | 100 | mock_open.side_effect = open_side_effect 101 | 102 | with patch.object(Path, "exists", return_value=True): 103 | info = guardian.get_system_info() 104 | 105 | assert info["hostname"] == "TestServer" 106 | assert info["unraid_version"] == "7.1.4" 107 | assert "timestamp" in info 108 | 109 | 110 | def test_create_restore_script(): 111 | """Test restoration script creation.""" 112 | system_info = {"timestamp": "2024-01-01T00:00:00", "hostname": "test-server"} 113 | 114 | script = guardian.create_restore_script(system_info) 115 | 116 | assert "#!/bin/bash" in script 117 | assert "test-server" in script 118 | assert "docker-compose up -d" in script 119 | 120 | 121 | def test_create_readme(): 122 | """Test README creation.""" 123 | system_info = {"hostname": "test-server"} 124 | 125 | readme = guardian.create_readme(system_info, 5) 126 | 127 | assert "# Unraid Backup Documentation" in readme 128 | assert "test-server" in readme 129 | assert "**Containers:** 5" in readme 130 | 131 | 132 | if __name__ == "__main__": 133 | pytest.main([__file__, "-v"]) 134 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Guide 2 | 3 | ## Permission Issues 4 | 5 | ### Problem: Container fails to write backup files 6 | 7 | **Symptoms:** 8 | - "Permission denied" errors in container logs 9 | - Backup files not appearing in output directory 10 | - Container exits with permission errors 11 | 12 | **Root Cause:** 13 | Mismatch between container user permissions and host directory ownership. The container runs as a specific user ID internally but needs to match your Unraid system's user permissions. 14 | 15 | ### Solutions 16 | 17 | #### 1. Set Correct PUID/PGID (Recommended) 18 | 19 | The container supports Unraid's standard PUID/PGID environment variables: 20 | 21 | ```bash 22 | # Standard Unraid user/group 23 | PUID=99 # 'nobody' user ID 24 | PGID=100 # 'users' group ID 25 | 26 | # Or use your custom user ID 27 | PUID=1000 # Your user ID 28 | PGID=1000 # Your group ID 29 | ``` 30 | 31 | **Docker run example:** 32 | ```bash 33 | docker run -d \ 34 | --name unraid-config-guardian \ 35 | -e PUID=99 -e PGID=100 \ 36 | -v /mnt/user/backups/unraid-docs:/output \ 37 | stephondoestech/unraid-config-guardian:latest 38 | ``` 39 | 40 | #### 2. Fix Output Directory Permissions 41 | 42 | If permission errors persist, check and fix directory ownership: 43 | 44 | ```bash 45 | # SSH into Unraid and check current permissions 46 | ls -la /mnt/user/backups/unraid-docs 47 | 48 | # Set correct ownership (use your PUID/PGID values) 49 | chown -R 99:100 /mnt/user/backups/unraid-docs 50 | 51 | # Verify permissions are set correctly 52 | ls -la /mnt/user/backups/unraid-docs 53 | ``` 54 | 55 | #### 3. Debug Container User Setup 56 | 57 | Verify the container is using the correct user: 58 | 59 | ```bash 60 | # Check what user the container is running as 61 | docker exec unraid-config-guardian id 62 | 63 | # Check container logs for user setup messages 64 | docker logs unraid-config-guardian | grep "Setting up user" 65 | 66 | # Expected output should show: 67 | # Setting up user permissions: PUID=99, PGID=100 68 | # User setup complete: guardian user now has UID=99, GID=100 69 | ``` 70 | 71 | ### Common Permission Scenarios 72 | 73 | | Scenario | PUID/PGID | Directory Owner | Result | 74 | |----------|-----------|-----------------|--------| 75 | | Standard Unraid | 99/100 | 99:100 | ✅ Works | 76 | | Custom user | 1000/1000 | 1000:1000 | ✅ Works | 77 | | No PUID/PGID set | Default (1000/1000) | 99:100 | ❌ Permission denied | 78 | | Wrong ownership | 99/100 | root:root | ❌ Permission denied | 79 | 80 | ### Quick Fix Commands 81 | 82 | ```bash 83 | # For standard Unraid setup (99:100) 84 | chown -R 99:100 /mnt/user/backups/unraid-docs 85 | 86 | # For custom user (replace with your IDs) 87 | chown -R 1000:1000 /mnt/user/backups/unraid-docs 88 | 89 | # Check if container can write to directory 90 | docker exec unraid-config-guardian touch /output/test.txt 91 | docker exec unraid-config-guardian ls -la /output/test.txt 92 | docker exec unraid-config-guardian rm /output/test.txt 93 | ``` 94 | 95 | ## Container Startup Issues 96 | 97 | ### Problem: Container exits immediately 98 | 99 | **Check container logs:** 100 | ```bash 101 | docker logs unraid-config-guardian 102 | ``` 103 | 104 | **Common causes:** 105 | - Missing required volume mounts (`/var/run/docker.sock`, `/boot`) 106 | - Permission issues with mounted directories 107 | - Invalid environment variables 108 | 109 | ### Problem: Web interface not accessible 110 | 111 | **Verify port mapping:** 112 | ```bash 113 | docker ps | grep unraid-config-guardian 114 | ``` 115 | 116 | **Check if port 7842 is available:** 117 | ```bash 118 | netstat -tlnp | grep 7842 119 | ``` 120 | 121 | ## Docker Socket Issues 122 | 123 | ### Problem: "Cannot connect to Docker daemon" 124 | 125 | **Symptoms:** 126 | - Container cannot list other containers 127 | - Docker API errors in logs 128 | 129 | **Solution:** 130 | Ensure Docker socket is properly mounted: 131 | ```bash 132 | -v /var/run/docker.sock:/var/run/docker.sock:ro 133 | ``` 134 | 135 | **Verify mount is working:** 136 | ```bash 137 | docker exec unraid-config-guardian ls -la /var/run/docker.sock 138 | ``` 139 | 140 | ## Boot Configuration Access 141 | 142 | ### Problem: Cannot read Unraid boot configuration 143 | 144 | **Symptoms:** 145 | - Missing system configuration in backup 146 | - Errors about `/boot` directory access 147 | 148 | **Solution:** 149 | Ensure boot directory is mounted read-only: 150 | ```bash 151 | -v /boot:/boot:ro 152 | ``` 153 | 154 | **Verify mount:** 155 | ```bash 156 | docker exec unraid-config-guardian ls -la /boot/config 157 | ``` 158 | 159 | ## Getting Help 160 | 161 | If you're still experiencing issues: 162 | 163 | 1. **Collect logs:** 164 | ```bash 165 | docker logs unraid-config-guardian > guardian-logs.txt 166 | ``` 167 | 168 | 2. **Check container status:** 169 | ```bash 170 | docker ps -a | grep unraid-config-guardian 171 | docker inspect unraid-config-guardian 172 | ``` 173 | 174 | 3. **Open an issue:** [GitHub Issues](https://github.com/stephondoestech/unraid-config-guardian/issues) 175 | - Include logs and container configuration 176 | - Specify your Unraid version and setup details 177 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Set timezone if provided (only if we have permission) 5 | if [ -n "$TZ" ] && [ -w /etc ]; then 6 | ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 7 | fi 8 | 9 | # Cache Unraid boot information while running as root (before user switching) 10 | echo "Caching Unraid system information..." 11 | 12 | # Try to get hostname from Unraid boot config 13 | if [ -f "/boot/config/ident.cfg" ]; then 14 | CACHED_HOSTNAME=$(grep "NAME=" /boot/config/ident.cfg 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1) 15 | if [ -n "$CACHED_HOSTNAME" ]; then 16 | export CACHED_HOSTNAME 17 | echo "Cached hostname: $CACHED_HOSTNAME" 18 | fi 19 | fi 20 | 21 | # Try to get Unraid version from changes.txt 22 | if [ -f "/boot/changes.txt" ]; then 23 | CACHED_UNRAID_VERSION=$(head -1 /boot/changes.txt 2>/dev/null | sed 's/# Version //' | awk '{print $1}') 24 | if [ -n "$CACHED_UNRAID_VERSION" ]; then 25 | export CACHED_UNRAID_VERSION 26 | echo "Cached Unraid version: $CACHED_UNRAID_VERSION" 27 | fi 28 | fi 29 | 30 | # Try alternative version detection in docker.cfg 31 | if [ -z "$CACHED_UNRAID_VERSION" ] && [ -f "/boot/config/docker.cfg" ]; then 32 | if grep -q "DOCKER_ENABLED" /boot/config/docker.cfg 2>/dev/null; then 33 | export CACHED_UNRAID_VERSION="Unraid (detected)" 34 | echo "Cached Unraid version: Unraid (detected from docker.cfg)" 35 | fi 36 | fi 37 | 38 | # Cache template directory accessibility and copy templates 39 | if [ -d "/boot/config/plugins/dockerMan/templates-user" ]; then 40 | export TEMPLATES_ACCESSIBLE="true" 41 | echo "Template directory accessible" 42 | 43 | # Create cache directory for templates in /output (persistent location) 44 | mkdir -p /output/cached-templates 45 | 46 | # Copy all XML templates to cache directory (as root, so we can read them) 47 | if [ "$(ls -A /boot/config/plugins/dockerMan/templates-user/*.xml 2>/dev/null)" ]; then 48 | cp /boot/config/plugins/dockerMan/templates-user/*.xml /output/cached-templates/ 2>/dev/null || true 49 | template_count=$(ls -1 /output/cached-templates/*.xml 2>/dev/null | wc -l) 50 | echo "Cached $template_count XML templates to /output/cached-templates" 51 | else 52 | echo "No XML templates found in templates-user directory" 53 | fi 54 | else 55 | export TEMPLATES_ACCESSIBLE="false" 56 | echo "Template directory not accessible" 57 | fi 58 | 59 | # Handle PUID/PGID for Unraid compatibility (only if running as root) 60 | if [ "$(id -u)" = "0" ] && [ -n "$PUID" ] && [ -n "$PGID" ]; then 61 | echo "Setting up user permissions: PUID=$PUID, PGID=$PGID" 62 | 63 | # Change guardian user/group IDs to match Unraid 64 | groupmod -o -g "$PGID" guardian 2>/dev/null || true 65 | usermod -o -u "$PUID" guardian 2>/dev/null || true 66 | 67 | # Update sudo rule for template refresh to work with any UID 68 | echo "%$PGID ALL=(root) NOPASSWD: /usr/local/bin/refresh-templates.sh" > /etc/sudoers.d/guardian-templates 69 | chmod 440 /etc/sudoers.d/guardian-templates 70 | 71 | # Ensure proper ownership of key directories 72 | chown -R guardian:guardian /config /output 2>/dev/null || true 73 | 74 | echo "User setup complete: guardian user now has UID=$(id -u guardian), GID=$(id -g guardian)" 75 | elif [ "$(id -u)" != "0" ]; then 76 | # Running as non-root user, just ensure directories exist 77 | echo "Running as non-root user ($(id -u):$(id -g)), ensuring directories exist" 78 | mkdir -p /config /output 2>/dev/null || true 79 | else 80 | echo "Running as root but no PUID/PGID specified, using default guardian user (1000:1000)" 81 | fi 82 | 83 | # Set up cron job if SCHEDULE is provided 84 | if [ -n "$SCHEDULE" ]; then 85 | echo "Setting up cron job with schedule: $SCHEDULE" 86 | PYTHON_BIN=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo python3) 87 | echo "Using Python interpreter for cron: $PYTHON_BIN" 88 | if [ "$(id -u)" = "0" ]; then 89 | # Running as root, use crontab for guardian user 90 | echo "$SCHEDULE cd /app && $PYTHON_BIN src/unraid_config_guardian.py --output /output >> /output/guardian.log 2>&1" | crontab -u guardian - 2>/dev/null || true 91 | # Start cron in background 92 | cron & 2>/dev/null || true 93 | else 94 | # Running as non-root, use user crontab 95 | echo "$SCHEDULE cd /app && $PYTHON_BIN src/unraid_config_guardian.py --output /output >> /output/guardian.log 2>&1" | crontab - 2>/dev/null || true 96 | fi 97 | fi 98 | 99 | # Create initial config if it doesn't exist 100 | if [ ! -f /config/config.yml ]; then 101 | echo "Creating initial configuration..." 102 | cat > /config/config.yml << EOF 103 | guardian: 104 | backup: 105 | mask_passwords: ${MASK_PASSWORDS:-true} 106 | include_system_info: ${INCLUDE_SYSTEM_INFO:-true} 107 | output_location: ${BACKUP_LOCATION:-/output} 108 | 109 | notifications: 110 | webhook_url: ${WEBHOOK_URL:-} 111 | email: ${EMAIL_NOTIFICATIONS:-} 112 | 113 | debug: ${DEBUG:-false} 114 | EOF 115 | 116 | # Set ownership if running as root 117 | if [ "$(id -u)" = "0" ]; then 118 | chown guardian:guardian /config/config.yml 2>/dev/null || true 119 | fi 120 | fi 121 | 122 | # Switch to guardian user and execute command 123 | if [ "$(id -u)" = "0" ] && command -v gosu >/dev/null 2>&1; then 124 | # Running as root and gosu is available 125 | if [ "$1" = 'python' ] || [ "$1" = 'src/unraid_config_guardian.py' ] || [ "$1" = 'src/web_gui.py' ]; then 126 | exec gosu guardian "$@" 127 | else 128 | exec "$@" 129 | fi 130 | else 131 | # Running as non-root or gosu not available, execute directly 132 | exec "$@" 133 | fi 134 | -------------------------------------------------------------------------------- /templates/containers.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Containers - Unraid Config Guardian{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Docker Containers

9 |

Overview of all Docker containers on your Unraid server

10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 | 21 | 24 |
25 |
26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% for container in containers %} 41 | 42 | 45 | 48 | 63 | 72 | 89 | 113 | 114 | {% endfor %} 115 | 116 |
NameImageStatusPortsVolumesEnvironment Variables
43 | {{ container.name }} 44 | 46 | {{ container.image }} 47 | 49 | {% if container.status == 'running' %} 50 | 51 | {{ container.status }} 52 | 53 | {% elif container.status == 'exited' %} 54 | 55 | {{ container.status }} 56 | 57 | {% else %} 58 | 59 | {{ container.status }} 60 | 61 | {% endif %} 62 | 64 | {% if container.ports %} 65 | {% for port in container.ports %} 66 | {{ port }} 67 | {% endfor %} 68 | {% else %} 69 | None 70 | {% endif %} 71 | 73 | {% if container.volumes %} 74 | 80 |
81 | {% for volume in container.volumes %} 82 | {{ volume }} 83 | {% endfor %} 84 |
85 | {% else %} 86 | None 87 | {% endif %} 88 |
90 | {% if container.environment %} 91 | 97 |
98 | {% for key, value in container.environment.items() %} 99 | 100 | {{ key }}: 101 | {% if value == '***MASKED***' %} 102 | {{ value }} 103 | {% else %} 104 | {{ value }} 105 | {% endif %} 106 | 107 | {% endfor %} 108 |
109 | {% else %} 110 | None 111 | {% endif %} 112 |
117 |
118 | 119 | {% if not containers %} 120 |
121 | 122 | No containers found. Make sure Docker is running and accessible. 123 |
124 | {% endif %} 125 | 126 | {% endblock %} 127 | 128 | {% block scripts %} 129 | 141 | {% endblock %} 142 | -------------------------------------------------------------------------------- /src/health_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Health check script for Unraid Config Guardian container 4 | """ 5 | 6 | import os 7 | import sys 8 | from pathlib import Path 9 | 10 | 11 | def check_docker_connection(): 12 | """Check if Docker daemon is accessible.""" 13 | try: 14 | # Try importing docker library 15 | import docker 16 | 17 | # Priority 1: Use DOCKER_HOST if set (recommended for socket proxy) 18 | docker_host = os.getenv("DOCKER_HOST") 19 | 20 | if docker_host: 21 | # DOCKER_HOST is set, use it directly 22 | client = docker.DockerClient(base_url=docker_host) 23 | client.ping() 24 | print(f"Successfully connected to Docker daemon at {docker_host}") 25 | return True 26 | 27 | # Priority 2: Fall back to DOCKER_SOCK_PATH for backward compatibility 28 | docker_sock_path = os.getenv("DOCKER_SOCK_PATH") 29 | 30 | if docker_sock_path: 31 | # Legacy variable - check if socket file exists 32 | docker_sock = Path(docker_sock_path) 33 | if not docker_sock.exists(): 34 | print(f"Docker socket not found at {docker_sock_path}") 35 | return False 36 | 37 | client = docker.DockerClient(base_url=f"unix://{docker_sock_path}") 38 | client.ping() 39 | print(f"Successfully connected to Docker daemon at {docker_sock_path}") 40 | return True 41 | 42 | # Priority 3: Default to socket proxy 43 | default_host = "tcp://docker-socket-proxy:2375" 44 | client = docker.DockerClient(base_url=default_host) 45 | client.ping() 46 | print(f"Successfully connected to Docker daemon at {default_host}") 47 | return True 48 | 49 | except ImportError as e: 50 | print(f"Docker library not available: {e}") 51 | print("This may be normal during container startup") 52 | return False 53 | except Exception as e: 54 | print(f"Docker connection failed: {e}") 55 | print("Tip: Set DOCKER_HOST environment variable to your Docker Socket Proxy") 56 | print("Example: DOCKER_HOST=tcp://docker-socket-proxy:2375") 57 | return False 58 | 59 | 60 | def check_output_directory(): 61 | """Check if output directory is writable.""" 62 | try: 63 | output_dir = Path(os.getenv("OUTPUT_DIR", "/output")) 64 | 65 | # Check if directory exists 66 | if not output_dir.exists(): 67 | print(f"Output directory does not exist: {output_dir}") 68 | return False 69 | 70 | # Check if directory is writable (less invasive test) 71 | if not os.access(output_dir, os.W_OK): 72 | print(f"Output directory not writable: {output_dir}") 73 | return False 74 | 75 | # Try to create a test file only if we have write access 76 | test_file = output_dir / ".health_check" 77 | try: 78 | test_file.touch() 79 | test_file.unlink() 80 | except PermissionError: 81 | # Directory exists but no write permission 82 | print(f"No write permission for output directory: {output_dir}") 83 | return False 84 | 85 | return True 86 | except Exception as e: 87 | print(f"Output directory check failed: {e}") 88 | return False 89 | 90 | 91 | def check_config_directory(): 92 | """Check if config directory is accessible.""" 93 | try: 94 | config_dir = Path("/config") 95 | if not config_dir.exists(): 96 | print(f"Config directory does not exist: {config_dir}") 97 | return False 98 | if not config_dir.is_dir(): 99 | print(f"Config path is not a directory: {config_dir}") 100 | return False 101 | return True 102 | except Exception as e: 103 | print(f"Config directory check failed: {e}") 104 | return False 105 | 106 | 107 | def check_application_files(): 108 | """Check if core application files are accessible.""" 109 | try: 110 | app_files = [ 111 | Path("/app/src/unraid_config_guardian.py"), 112 | Path("/app/src/web_gui.py"), 113 | ] 114 | 115 | for app_file in app_files: 116 | if not app_file.exists(): 117 | print(f"Core application file missing: {app_file}") 118 | return False 119 | 120 | return True 121 | except Exception as e: 122 | print(f"Application files check failed: {e}") 123 | return False 124 | 125 | 126 | def main(): 127 | """Main health check function.""" 128 | print(f"Health check running as user: {os.getuid()}:{os.getgid()}") 129 | 130 | checks = [ 131 | ("Application files", check_application_files), 132 | ("Config directory", check_config_directory), 133 | ("Output directory", check_output_directory), 134 | ("Docker connection", check_docker_connection), 135 | ] 136 | 137 | all_passed = True 138 | results = [] 139 | 140 | for check_name, check_func in checks: 141 | try: 142 | if check_func(): 143 | print(f"✅ {check_name}: OK") 144 | results.append(f"{check_name}: OK") 145 | else: 146 | print(f"❌ {check_name}: FAILED") 147 | results.append(f"{check_name}: FAILED") 148 | all_passed = False 149 | except Exception as e: 150 | print(f"❌ {check_name}: ERROR - {e}") 151 | results.append(f"{check_name}: ERROR - {e}") 152 | all_passed = False 153 | 154 | # Summary 155 | print("-" * 40) 156 | for result in results: 157 | print(f" {result}") 158 | 159 | if all_passed: 160 | print("🟢 All health checks passed") 161 | sys.exit(0) 162 | else: 163 | print("🔴 One or more health checks failed") 164 | sys.exit(1) 165 | 166 | 167 | if __name__ == "__main__": 168 | main() 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Unraid Config Guardian 4 | 5 | Unraid Config Guardian Logo 6 | 7 | [![CI/CD Pipeline](https://github.com/stephondoestech/unraid-config-guardian/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/stephondoestech/unraid-config-guardian/actions/workflows/ci-cd.yml) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/stephondoestech/unraid-config-guardian)](https://hub.docker.com/r/stephondoestech/unraid-config-guardian) 9 | [![GitHub release](https://img.shields.io/github/release/stephondoestech/unraid-config-guardian.svg)](https://github.com/stephondoestech/unraid-config-guardian/releases) 10 | [![License](https://img.shields.io/github/license/stephondoestech/unraid-config-guardian)](LICENSE) 11 | 12 | **Your Unraid flash drive crashed and you don't have a backup?** 13 | 14 | This tool saves you from complete disaster by automatically documenting your entire Unraid configuration. 15 | 16 |
17 | 18 | ## Flash Drive Disaster Recovery 19 | 20 | **The Problem:** Your Unraid flash drive dies, taking with it: 21 | - All Docker container configurations 22 | - System settings and user shares 23 | - Plugin configurations and templates 24 | - Years of careful setup work 25 | 26 | **The Solution:** Config Guardian automatically backs up everything needed to rebuild your server: 27 | - **All running containers** → Docker templates + compose files 28 | - **System configuration** → Settings, shares, plugins 29 | - **Complete rebuild guide** → Step-by-step restoration 30 | - **Change tracking** → See what changed between backups 31 | 32 | ## Application 33 | 34 |
35 | 36 | ### Dashboard Overview 37 | Dashboard Screenshot 38 | 39 | ### Container Management 40 | Container Management Screenshot 41 | 42 |
43 | 44 | 45 | 46 | ## Emergency Setup (Flash Drive Died) 47 | 48 | ### Quick Install on Fresh Unraid 49 | 50 | 1. **Install fresh Unraid** on new hardware/flash drive 51 | 2. **Set up basic array** and enable Docker 52 | 3. **Install Config Guardian:** 53 | 54 | ```bash 55 | # SSH into Unraid and run: 56 | mkdir -p /mnt/user/appdata/unraid-config-guardian 57 | mkdir -p /mnt/user/backups/unraid-docs 58 | 59 | docker run -d \ 60 | --name unraid-config-guardian \ 61 | --restart unless-stopped \ 62 | -p 7842:7842 \ 63 | -v /mnt/user/appdata/unraid-config-guardian:/config \ 64 | -v /mnt/user/backups/unraid-docs:/output \ 65 | -v /var/run/docker.sock:/var/run/docker.sock:ro \ 66 | -v /boot:/boot:ro \ 67 | -e PUID=99 -e PGID=100 \ 68 | -e SCHEDULE="0 2 * * 0" \ 69 | stephondoestech/unraid-config-guardian:latest 70 | ``` 71 | 72 | 4. **Access:** `http://your-unraid-ip:7842` 73 | 5. **Generate backup** to start protecting your new setup 74 | 75 | ### Preventive Setup (Normal Use) 76 | 77 | Install via **Community Apps** → Search "Config Guardian" → Install 78 | 79 | **Pro Tip:** Set up weekly automated backups immediately after configuring any new containers! 80 | 81 | ## Your Backup Contains Everything Needed 82 | 83 | ``` 84 | unraid-backup/ 85 | ├── container-templates.zip # Native Unraid XML templates → Drop in Docker tab 86 | ├── docker-compose.yml # Emergency fallback containers 87 | ├── unraid-config.json # System settings, shares, plugins 88 | ├── restore.sh # Automated restoration script 89 | ├── changes.log # What changed since last backup 90 | └── README.md # Step-by-step recovery guide 91 | ``` 92 | 93 | **Data Sources:** 94 | - Running Docker containers (via Docker API) 95 | - Unraid system configuration (`/boot/config/`) 96 | - User shares and disk settings 97 | - Plugin configurations and templates 98 | 99 | ## Super Simple Recovery 100 | 101 | **When disaster strikes:** 102 | 103 | 1. **Fresh Unraid install** → Set up array 104 | 2. **Restore from backup:** 105 | ```bash 106 | cd /mnt/user/backups/unraid-docs/latest 107 | bash restore.sh 108 | ``` 109 | 3. **Add containers:** Docker tab → Your templates are in the dropdown 110 | 4. **Copy back appdata** from your separate backups 111 | 112 | **That's it!** Your entire server configuration is restored. 113 | 114 | ## IMPORTANT: This is NOT a Data Backup Solution 115 | 116 | **Config Guardian only backs up your CONFIGURATION, not your data.** You still need a proper backup solution for your appdata and media files. 117 | 118 | **Recommended backup solutions:** 119 | - **Kopia** - Modern, fast, encrypted backups 120 | - **Duplicacy** - Web-based backup management 121 | - **Rustic** - Rust-based restic alternative 122 | - **Unraid Plugins:** CA Backup/Restore, Appdata Backup 123 | 124 | **What Config Guardian backs up:** 125 | - Docker container configurations and templates 126 | - Unraid system settings and shares 127 | - Plugin configurations 128 | - Recovery scripts and documentation 129 | 130 | **What you still need to backup separately:** 131 | - `/mnt/user/appdata/` (your container data) 132 | - `/mnt/user/` (your media and files) 133 | - Any custom scripts or configurations 134 | 135 | ## Configuration 136 | 137 | **Essential Settings:** 138 | ```bash 139 | SCHEDULE="0 2 * * 0" # Weekly backup (Sunday 2 AM) 140 | PUID=99 PGID=100 # Standard Unraid permissions 141 | MASK_PASSWORDS=true # Hide sensitive data in backups 142 | ``` 143 | 144 | **Common Issues:** 145 | - **No templates in dropdown:** Enable Template Authoring Mode in Docker settings 146 | - **Permission errors:** See [troubleshooting guide](docs/troubleshooting.md) 147 | 148 | ## Manual Usage 149 | 150 | ```bash 151 | # Generate backup now 152 | docker exec unraid-config-guardian python3 src/unraid_config_guardian.py 153 | 154 | # View logs 155 | docker logs unraid-config-guardian 156 | 157 | # Check what changed 158 | cat /mnt/user/backups/unraid-docs/latest/changes.log 159 | ``` 160 | 161 | ## License 162 | 163 | This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. 164 | 165 | --- 166 | 167 | **Built by [Stephon Parker](https://github.com/stephondoestech) for the Unraid community** 168 | 169 | *Development supported by Claude (Anthropic)* 170 | 171 | [⭐ Star](https://github.com/stephondoestech/unraid-config-guardian/stargazers) | [🐛 Issues](https://github.com/stephondoestech/unraid-config-guardian/issues) | [💡 Features](https://github.com/stephondoestech/unraid-config-guardian/issues) 172 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Unraid Config Guardian Makefile 2 | 3 | PYTHON ?= python3 4 | PIP ?= pip3 5 | APP_DIRS := src tests 6 | DOCKER_COMPOSE := docker-compose 7 | DOCKER_COMPOSE_DEV := $(DOCKER_COMPOSE) -f docker-compose.dev.yml 8 | IMAGE_NAME := unraid-config-guardian 9 | IMAGE_TAG ?= latest 10 | CONTAINER_NAME := unraid-config-guardian 11 | 12 | BLACK := black $(APP_DIRS) 13 | ISORT := isort $(APP_DIRS) 14 | FLAKE8 := flake8 $(APP_DIRS) --max-line-length=100 --extend-ignore=E203,W503,E231,E221,E272,E201,E202,E702,E271 15 | PYTEST := pytest tests/ -v 16 | PYTEST_COV := $(PYTEST) --cov=src/ --cov-report=html --cov-report=term 17 | 18 | COLOR ?= 1 19 | ifeq ($(COLOR),1) 20 | BLUE := \033[0;34m 21 | GREEN := \033[0;32m 22 | YELLOW := \033[0;33m 23 | NC := \033[0m 24 | else 25 | BLUE := 26 | GREEN := 27 | YELLOW := 28 | NC := 29 | endif 30 | 31 | .PHONY: help dev-setup install install-dev hooks lint format format-check type-check test test-cov run run-gui run-prod health clean clean-all check fix-and-check docker-build docker-run docker-dev docker-stop docker-stop-dev docker-logs docker-logs-dev docker-shell docker-health docker-clean ci-test ci-build ci-push tag release-patch release-minor release-major list-releases all deploy 32 | 33 | help: ## Show this help message 34 | @echo "$(BLUE)Unraid Config Guardian - Development Commands$(NC)" 35 | @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-18s$(NC) %s\n", $$1, $$2}' 36 | 37 | dev-setup: ## Create venv and print next steps 38 | @echo "$(BLUE)Setting up development environment...$(NC)" 39 | $(PYTHON) -m venv venv 40 | @echo "$(YELLOW)Activate with: source venv/bin/activate$(NC)" 41 | @echo "$(YELLOW)Then run: make install-dev$(NC)" 42 | 43 | install: ## Install runtime dependencies 44 | $(PIP) install -r requirements.txt 45 | 46 | hooks: ## Install git hooks 47 | pre-commit install --install-hooks 48 | cp .githooks/pre-push .git/hooks/pre-push 49 | chmod +x .git/hooks/pre-push 50 | 51 | install-dev: install hooks ## Install runtime + development dependencies 52 | $(PIP) install -r requirements-dev.txt 53 | 54 | lint: ## Run flake8 55 | $(FLAKE8) 56 | 57 | format: ## Format with black and isort 58 | $(BLACK) 59 | $(ISORT) 60 | 61 | format-check: ## Check formatting 62 | $(BLACK) --check 63 | $(ISORT) --check-only 64 | 65 | type-check: ## Run type checks with mypy 66 | mypy src/ --ignore-missing-imports 67 | 68 | test: ## Run tests 69 | $(PYTEST) 70 | 71 | test-cov: ## Run tests with coverage 72 | $(PYTEST_COV) 73 | 74 | run: ## Run the CLI locally (debug) 75 | mkdir -p ./output 76 | $(PYTHON) src/unraid_config_guardian.py --output ./output --debug 77 | 78 | run-gui: ## Run the dev web GUI 79 | mkdir -p ./output 80 | $(PYTHON) src/web_gui_dev.py 81 | 82 | run-prod: ## Run the CLI in production mode 83 | mkdir -p ./output 84 | $(PYTHON) src/unraid_config_guardian.py --output ./output 85 | 86 | health: ## Run health check 87 | $(PYTHON) src/health_check.py 88 | 89 | docker-build: ## Build Docker image 90 | docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . 91 | 92 | docker-run: ## Run Docker container (production/Unraid) 93 | $(DOCKER_COMPOSE) up -d 94 | 95 | docker-dev: ## Run Docker container (local development) 96 | mkdir -p ./config ./output 97 | $(DOCKER_COMPOSE_DEV) up -d 98 | 99 | docker-stop: ## Stop Docker container 100 | $(DOCKER_COMPOSE) down 101 | 102 | docker-stop-dev: ## Stop development Docker container 103 | $(DOCKER_COMPOSE_DEV) down 104 | 105 | docker-logs: ## Show Docker container logs 106 | $(DOCKER_COMPOSE) logs -f 107 | 108 | docker-logs-dev: ## Show development container logs 109 | $(DOCKER_COMPOSE_DEV) logs -f 110 | 111 | docker-shell: ## Get shell in running container 112 | docker exec -it $(CONTAINER_NAME) /bin/bash 113 | 114 | docker-health: ## Check container health 115 | docker exec $(CONTAINER_NAME) $(PYTHON) src/health_check.py 116 | 117 | docker-clean: ## Clean up Docker resources 118 | $(DOCKER_COMPOSE) down --volumes --rmi all 119 | docker system prune -f 120 | 121 | clean: ## Clean up generated files 122 | find . -type f -name "*.pyc" -delete 123 | find . -type d -name "__pycache__" -delete 124 | find . -type d -name "*.egg-info" -exec rm -rf {} + 125 | rm -rf build/ dist/ .pytest_cache/ .coverage htmlcov/ output/ logs/ 126 | 127 | clean-all: clean docker-clean ## Clean everything including Docker resources 128 | 129 | check: format-check lint type-check test ## Run all quality checks 130 | 131 | fix-and-check: format lint type-check test ## Auto-format then run checks 132 | 133 | all: clean install-dev check docker-build ## Run complete development workflow 134 | 135 | deploy: check docker-build docker-run ## Deploy to production 136 | 137 | ci-test: ## Run CI tests (coverage XML for CI) 138 | $(PYTEST) --cov=src/ --cov-report=xml --cov-report=term 139 | 140 | ci-build: ## Build multi-platform image 141 | docker buildx build --platform linux/amd64,linux/arm64 -t $(IMAGE_NAME):$(IMAGE_TAG) . 142 | 143 | ci-push: ## Push to Docker Hub 144 | docker push $(IMAGE_NAME):$(IMAGE_TAG) 145 | 146 | tag: ## Create and push a new tag (usage: make tag VERSION=v1.0.0) 147 | @if [ -z "$(VERSION)" ]; then echo "Usage: make tag VERSION=v1.0.0"; exit 1; fi 148 | @VERSION_NUM=$$(echo $(VERSION) | sed 's/^v//'); \ 149 | sed -i '' "s/__version__ = \".*\"/__version__ = \"$$VERSION_NUM\"/" src/version.py; \ 150 | git add src/version.py; \ 151 | git commit -m "Update version to $$VERSION_NUM"; \ 152 | git tag -a $(VERSION) -m "Release $(VERSION)"; \ 153 | git push origin main; \ 154 | git push origin $(VERSION) 155 | 156 | release-patch: ## Create patch release (v1.0.0 -> v1.0.1) 157 | @LATEST=$$(git tag -l "v*.*.*" | sort -V | tail -1); \ 158 | if [ -z "$$LATEST" ]; then NEW_VERSION="v1.0.0"; \ 159 | else MAJOR=$$(echo $$LATEST | cut -d. -f1); MINOR=$$(echo $$LATEST | cut -d. -f2); PATCH=$$(echo $$LATEST | cut -d. -f3); \ 160 | NEW_PATCH=$$((PATCH + 1)); NEW_VERSION="$$MAJOR.$$MINOR.$$NEW_PATCH"; fi; \ 161 | echo "Next version: $$NEW_VERSION"; \ 162 | make tag VERSION=$$NEW_VERSION 163 | 164 | release-minor: ## Create minor release (v1.0.x -> v1.1.0) 165 | @LATEST=$$(git tag -l "v*.*.*" | sort -V | tail -1); \ 166 | if [ -z "$$LATEST" ]; then NEW_VERSION="v1.0.0"; \ 167 | else MAJOR=$$(echo $$LATEST | cut -d. -f1); MINOR=$$(echo $$LATEST | cut -d. -f2); \ 168 | NEW_MINOR=$$((MINOR + 1)); NEW_VERSION="$$MAJOR.$$NEW_MINOR.0"; fi; \ 169 | echo "Next version: $$NEW_VERSION"; \ 170 | make tag VERSION=$$NEW_VERSION 171 | 172 | release-major: ## Create major release (v1.x.x -> v2.0.0) 173 | @LATEST=$$(git tag -l "v*.*.*" | sort -V | tail -1); \ 174 | if [ -z "$$LATEST" ]; then NEW_VERSION="v1.0.0"; \ 175 | else MAJOR=$$(echo $$LATEST | cut -d. -f1 | sed 's/v//'); \ 176 | NEW_MAJOR=$$((MAJOR + 1)); NEW_VERSION="v$$NEW_MAJOR.0.0"; fi; \ 177 | echo "Next version: $$NEW_VERSION"; \ 178 | make tag VERSION=$$NEW_VERSION 179 | 180 | list-releases: ## List recent releases 181 | @git tag -l "v*.*.*" | sort -V | tail -10 182 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Unraid Config Guardian 2 | 3 | Thank you for your interest in contributing! This guide will help you get started. 4 | 5 | ## 🚀 Quick Development Setup 6 | 7 | ```bash 8 | # Clone the repository 9 | git clone https://github.com/stephondoestech/unraid-config-guardian.git 10 | cd unraid-config-guardian 11 | 12 | # Set up development environment 13 | make dev-setup 14 | source venv/bin/activate 15 | make install-dev 16 | 17 | # Run tests and quality checks 18 | make check 19 | 20 | # Start development server 21 | make run-gui # Web GUI at http://localhost:7842 22 | # OR 23 | make run # CLI version 24 | ``` 25 | 26 | ## 🧪 Testing 27 | 28 | ### Local Testing 29 | ```bash 30 | # Run all tests 31 | make test 32 | 33 | # Run with coverage 34 | make test-cov 35 | 36 | # Run specific test file 37 | pytest tests/test_unraid_config_guardian.py -v 38 | 39 | # Run linting and type checking 40 | make lint 41 | make type-check 42 | make format 43 | ``` 44 | 45 | ### Docker Testing 46 | ```bash 47 | # Build and test locally 48 | make docker-build 49 | make docker-dev 50 | 51 | # Access at http://localhost:7842 52 | ``` 53 | 54 | ## 📋 Development Workflow 55 | 56 | 1. **Create a feature branch:** 57 | ```bash 58 | git checkout -b feature/your-feature-name 59 | ``` 60 | 61 | 2. **Make your changes:** 62 | - Write tests for new functionality 63 | - Follow existing code style 64 | - Update documentation if needed 65 | 66 | 3. **Test your changes:** 67 | ```bash 68 | make check # Runs all quality checks 69 | ``` 70 | 71 | 4. **Commit and push:** 72 | ```bash 73 | git add . 74 | git commit -m "Add: description of your changes" 75 | git push origin feature/your-feature-name 76 | ``` 77 | 78 | 5. **Create a Pull Request:** 79 | - Use the PR template 80 | - Link any related issues 81 | - Ensure all CI checks pass 82 | 83 | ## 🎯 Areas for Contribution 84 | 85 | ### High Priority 86 | - **Container Detection**: Improve container analysis and edge cases 87 | - **Backup Formats**: Add support for other backup formats (Kubernetes YAML, etc.) 88 | - **Notifications**: Implement email/webhook notifications 89 | - **Scheduling**: Enhance cron scheduling with more options 90 | - **Testing**: Expand test coverage, especially integration tests 91 | 92 | ### Medium Priority 93 | - **UI Improvements**: Enhance the web interface 94 | - **Documentation**: Better user guides and API docs 95 | - **Performance**: Optimize for servers with many containers 96 | - **Plugins**: Support for Unraid plugin backup 97 | 98 | ### Good First Issues 99 | - **Bug Fixes**: Check GitHub issues labeled `good first issue` 100 | - **Documentation**: Improve README, add examples 101 | - **Templates**: Create more Unraid Community App templates 102 | - **Testing**: Add unit tests for specific functions 103 | 104 | ## 🏗️ Architecture Overview 105 | 106 | ``` 107 | src/ 108 | ├── unraid_config_guardian.py # Main CLI application 109 | ├── web_gui.py # Production web interface 110 | ├── web_gui_dev.py # Development web interface with mocks 111 | └── health_check.py # Container health monitoring 112 | 113 | templates/ # HTML templates for web UI 114 | ├── base.html # Base template 115 | ├── dashboard.html # Main dashboard 116 | ├── containers.html # Container overview 117 | └── backups.html # Backup management 118 | 119 | docker/ 120 | └── entrypoint.sh # Container entrypoint script 121 | ``` 122 | 123 | ## 🎨 Code Style Guidelines 124 | 125 | ### Python Code 126 | - **Black** for formatting: `make format` 127 | - **Flake8** for linting: `make lint` 128 | - **MyPy** for type checking: `make type-check` 129 | - **Line length**: 88 characters (Black default) 130 | - **Type hints**: Required for all functions 131 | - **Docstrings**: Google-style docstrings for all public functions 132 | 133 | ### Example: 134 | ```python 135 | def process_container(container: Dict[str, Any]) -> ContainerInfo: 136 | """Process a Docker container and extract configuration. 137 | 138 | Args: 139 | container: Raw Docker container data 140 | 141 | Returns: 142 | ContainerInfo object with parsed configuration 143 | 144 | Raises: 145 | ValueError: If container data is invalid 146 | """ 147 | # Implementation here 148 | ``` 149 | 150 | ### Web Templates 151 | - **Bootstrap 5** for styling 152 | - **Semantic HTML** structure 153 | - **Accessibility** considerations (ARIA labels, etc.) 154 | - **Mobile-responsive** design 155 | 156 | ## 🔄 CI/CD Pipeline 157 | 158 | Our GitHub Actions workflow: 159 | 160 | 1. **Test Stage**: Runs tests, linting, type checking 161 | 2. **Build Stage**: Builds Docker image for multiple platforms 162 | 3. **Security Stage**: Vulnerability scanning with Trivy 163 | 4. **Deploy Stage**: Pushes to Docker Hub (main branch & tags) 164 | 165 | ### Running CI Locally 166 | ```bash 167 | # Run the same checks as CI 168 | make check 169 | 170 | # Build multi-platform (requires Docker Buildx) 171 | make ci-build 172 | 173 | # Test Docker image 174 | make docker-build 175 | make docker-dev 176 | ``` 177 | 178 | ## 📦 Release Process 179 | 180 | ### Creating a Release 181 | 182 | 1. **Update version numbers** in relevant files 183 | 2. **Update CHANGELOG.md** with new features/fixes 184 | 3. **Create and push a tag:** 185 | ```bash 186 | make tag VERSION=v1.2.0 187 | ``` 188 | 4. **GitHub Actions** will automatically: 189 | - Run full test suite 190 | - Build multi-platform Docker images 191 | - Push to Docker Hub 192 | - Create GitHub release 193 | 194 | ### Version Numbering 195 | We follow [Semantic Versioning](https://semver.org/): 196 | - `v1.0.0` - Major release (breaking changes) 197 | - `v1.1.0` - Minor release (new features) 198 | - `v1.1.1` - Patch release (bug fixes) 199 | 200 | ## 🐛 Bug Reports 201 | 202 | When reporting bugs, please include: 203 | - Unraid version 204 | - Container/application version 205 | - Steps to reproduce 206 | - Expected vs actual behavior 207 | - Relevant logs 208 | - Screenshots (for UI issues) 209 | 210 | ## 💡 Feature Requests 211 | 212 | For new features: 213 | - Describe the use case 214 | - Explain why it would be valuable 215 | - Consider implementation complexity 216 | - Provide mockups for UI changes 217 | 218 | ## 🔒 Security 219 | 220 | For security issues: 221 | - **DO NOT** open public GitHub issues 222 | - Email: security@stephondoestech.com 223 | - Include details about the vulnerability 224 | - Allow reasonable time for fixes before disclosure 225 | 226 | ## 📄 License 227 | 228 | By contributing, you agree that your contributions will be licensed under the MIT License. 229 | 230 | ## 🙋‍♂️ Questions? 231 | 232 | - **GitHub Discussions**: For general questions 233 | - **GitHub Issues**: For bug reports and feature requests 234 | - **Discord**: [Join our community](https://discord.gg/unraid-config-guardian) (coming soon) 235 | 236 | Thank you for contributing to Unraid Config Guardian! 🎉 237 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Unraid Config Guardian{% endblock %} 7 | 8 | 9 | 27 | 28 | 29 | 30 | 57 | 58 | 59 |
60 | {% block content %}{% endblock %} 61 |
62 | 63 | 64 | 80 | 81 | 82 | 167 | {% block scripts %}{% endblock %} 168 | 169 | 170 | -------------------------------------------------------------------------------- /templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Dashboard - Unraid Config Guardian{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Dashboard

9 |

Monitor your Unraid server and manage configuration backups

10 |
11 |
12 | 13 | {% if stats.error %} 14 | 17 | {% else %} 18 | 19 | 20 |
21 |
22 |
23 |
24 | 25 |

{{ stats.total_containers }}

26 |

Total Containers

27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 |

{{ stats.running_containers }}

35 |

Running

36 |
37 |
38 |
39 |
40 |
41 |
42 | 43 |

{{ stats.system_info.hostname }}

44 |

Hostname

45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 |

{{ stats.system_info.get('unraid_version', 'Unknown') }}

53 |

Unraid Version

54 |
55 |
56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 |
64 |
Quick Actions
65 |
66 | 80 |
81 |
82 |
83 | 84 | 85 |
86 |
87 |
88 |
89 |
System Information
90 |
91 |
92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 |
Hostname:{{ stats.system_info.hostname }}
Unraid Version:{{ stats.system_info.get('unraid_version', 'Unknown') }}
Guardian Version:{{ stats.system_info.get('guardian_version', 'Unknown') }}
Last Scan:{{ stats.system_info.timestamp[:19] if stats.system_info.timestamp else 'Never' }}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
Last Backup
117 |
118 |
119 | {% if stats.last_backup %} 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
Date:{{ stats.last_backup.timestamp[:19] if stats.last_backup.timestamp else 'Unknown' }}
Containers:{{ stats.last_backup.containers }}
Size:{{ "%.1f"|format(stats.last_backup.size / 1024) }} KB
134 | {% else %} 135 |

No backups found

136 | 139 | {% endif %} 140 |
141 |
142 |
143 |
144 | 145 | 146 |
147 |
148 |
149 |
150 |
Current Status
151 |
152 |
153 | {% if stats.status.running %} 154 |
155 | 156 | Backup in progress: {{ stats.status.message }} ({{ stats.status.progress }}%) 157 |
158 |
160 |
161 |
162 | {% elif stats.status.last_error %} 163 |
164 | 165 | Last backup failed: {{ stats.status.last_error }} 166 |
167 | {% else %} 168 |
169 | 170 | System ready for backup 171 | {% if stats.status.last_run %} 172 |
Last run: {{ stats.status.last_run[:19] }} 173 | {% endif %} 174 |
175 | {% endif %} 176 |
177 |
178 |
179 |
180 | 181 | {% endif %} 182 | {% endblock %} 183 | -------------------------------------------------------------------------------- /docs/DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment Guide 2 | 3 | ## 🚀 CI/CD Pipeline Setup 4 | 5 | ### 1. GitHub Repository Setup 6 | 7 | The repository now includes a complete CI/CD pipeline with: 8 | 9 | ``` 10 | .github/ 11 | ├── workflows/ 12 | │ ├── ci-cd.yml # Main CI/CD pipeline 13 | │ └── release.yml # Automatic releases 14 | └── ISSUE_TEMPLATE/ 15 | ├── bug_report.md # Bug report template 16 | └── feature_request.md # Feature request template 17 | ``` 18 | 19 | ### 2. Docker Hub Configuration 20 | 21 | **Required GitHub Secrets:** 22 | 23 | 1. **Go to Repository Settings** → **Secrets and variables** → **Actions** 24 | 2. **Add these secrets:** 25 | 26 | ``` 27 | DOCKER_USERNAME = your-dockerhub-username 28 | DOCKER_TOKEN = your-dockerhub-access-token 29 | ``` 30 | 31 | **Creating Docker Hub Token:** 32 | 1. Login to [Docker Hub](https://hub.docker.com) 33 | 2. Go to **Account Settings** → **Security** 34 | 3. Click **New Access Token** 35 | 4. Name: `unraid-config-guardian-github` 36 | 5. Permissions: **Read, Write, Delete** 37 | 6. Copy the token and add it to GitHub secrets 38 | 39 | ### 3. Automated Workflows 40 | 41 | The CI/CD pipeline automatically: 42 | 43 | **On Pull Requests:** 44 | - ✅ Runs tests (pytest) 45 | - ✅ Code quality checks (black, flake8, mypy) 46 | - ✅ Builds Docker image 47 | - ✅ Security scanning (Trivy) 48 | 49 | **On Push to `main`:** 50 | - ✅ Everything above, plus: 51 | - ✅ Deploys to Docker Hub as `latest` 52 | - ✅ Multi-platform build (AMD64 + ARM64) 53 | 54 | **On Version Tags (`v1.0.0`):** 55 | - ✅ Everything above, plus: 56 | - ✅ Creates GitHub Release 57 | - ✅ Deploys versioned Docker image 58 | - ✅ Updates Docker Hub description 59 | 60 | ### 4. Release Process 61 | 62 | **Create a new release:** 63 | 64 | ```bash 65 | # Make sure your changes are committed and pushed 66 | git add . 67 | git commit -m "Add: new feature description" 68 | git push origin main 69 | 70 | # Create and push a version tag 71 | make tag VERSION=v1.0.0 72 | ``` 73 | 74 | **What happens automatically:** 75 | 1. GitHub Actions builds and tests 76 | 2. Multi-platform Docker image is created 77 | 3. Image is pushed to Docker Hub 78 | 4. GitHub Release is created with changelog 79 | 5. Unraid users can pull the new version 80 | 81 | ## 📦 Docker Hub Deployment 82 | 83 | ### Manual Deployment (if needed) 84 | 85 | ```bash 86 | # Build for multiple platforms 87 | docker buildx create --use 88 | docker buildx build --platform linux/amd64,linux/arm64 \ 89 | -t stephondoestech/unraid-config-guardian:latest \ 90 | -t stephondoestech/unraid-config-guardian:v1.0.0 \ 91 | --push . 92 | ``` 93 | 94 | ### Image Tags Strategy 95 | 96 | - **`latest`** - Latest stable release from main branch 97 | - **`v1.0.0`** - Specific version tags 98 | - **`main`** - Latest development from main branch 99 | - **`develop`** - Development branch builds 100 | 101 | ## 🐳 Production Deployment 102 | 103 | ### Option 1: Docker Command (Unraid) 104 | 105 | ```bash 106 | docker run -d \ 107 | --name unraid-config-guardian \ 108 | --restart unless-stopped \ 109 | -p 7842:7842 \ 110 | -v /mnt/user/appdata/unraid-config-guardian:/config \ 111 | -v /mnt/user/backups/unraid-docs:/output \ 112 | -v /var/run/docker.sock:/var/run/docker.sock:ro \ 113 | -v /boot:/boot:ro \ 114 | -e PUID=99 -e PGID=100 -e TZ=America/New_York \ 115 | -e MASK_PASSWORDS=true \ 116 | stephondoestech/unraid-config-guardian:latest 117 | ``` 118 | 119 | ### Option 2: Docker Compose (Unraid) 120 | 121 | ```bash 122 | # Copy docker-compose.yml to Unraid 123 | scp docker-compose.yml root@unraid-server:/mnt/user/appdata/unraid-config-guardian/ 124 | 125 | # SSH into Unraid 126 | ssh root@unraid-server 127 | cd /mnt/user/appdata/unraid-config-guardian 128 | 129 | # Deploy 130 | docker-compose up -d 131 | ``` 132 | 133 | ### Option 3: Unraid Community Apps 134 | 135 | 1. **Apps** → **Search** → "Config Guardian" 136 | 2. **Install** and configure paths 137 | 3. **Start** container 138 | 139 | ## 🔧 Environment Configuration 140 | 141 | ### Production Environment Variables 142 | 143 | ```bash 144 | # Required 145 | PUID=99 # Unraid user ID 146 | PGID=100 # Unraid group ID 147 | TZ=America/New_York # Timezone 148 | 149 | # Optional 150 | SCHEDULE=0 2 * * 0 # Backup schedule (cron) 151 | MASK_PASSWORDS=true # Security 152 | INCLUDE_SYSTEM_INFO=true # System details 153 | DEBUG=false # Debug mode 154 | 155 | # Advanced (optional) 156 | WEBHOOK_URL=https://... # Notifications 157 | EMAIL_NOTIFICATIONS=admin@domain.com 158 | WEB_HOST=0.0.0.0 # Web server host 159 | WEB_PORT=8080 # Web server port 160 | ``` 161 | 162 | ### Volume Mounts 163 | 164 | ```bash 165 | # Required mounts 166 | /mnt/user/appdata/unraid-config-guardian:/config # App config 167 | /mnt/user/backups/unraid-docs:/output # Generated backups 168 | /var/run/docker.sock:/var/run/docker.sock:ro # Docker access 169 | /boot:/boot:ro # Unraid flash drive 170 | 171 | # Optional mounts 172 | /mnt/user:/mnt/user:ro # User shares 173 | /etc/unraid:/etc/unraid:ro # System config 174 | ``` 175 | 176 | ## 🌐 Accessing the Application 177 | 178 | - **Web Interface**: `http://your-unraid-ip:7842` 179 | - **Health Check**: `http://your-unraid-ip:7842/health` (coming soon) 180 | - **API Endpoints**: `http://your-unraid-ip:7842/api/` 181 | 182 | ## 📊 Monitoring 183 | 184 | ### Container Health 185 | ```bash 186 | # Check container status 187 | docker ps | grep unraid-config-guardian 188 | 189 | # View logs 190 | docker logs unraid-config-guardian -f 191 | 192 | # Health check 193 | docker exec unraid-config-guardian python src/health_check.py 194 | ``` 195 | 196 | ### Backup Verification 197 | ```bash 198 | # Check generated files 199 | ls -la /mnt/user/backups/unraid-docs/ 200 | 201 | # Verify backup content 202 | cat /mnt/user/backups/unraid-docs/unraid-config.json | jq . 203 | ``` 204 | 205 | ## 🔍 Troubleshooting 206 | 207 | ### Common Issues 208 | 209 | **Container won't start:** 210 | - Check Docker socket permissions 211 | - Verify mount paths exist 212 | - Check container logs: `docker logs unraid-config-guardian` 213 | 214 | **No containers detected:** 215 | - Verify Docker socket mount: `/var/run/docker.sock` 216 | - Check PUID/PGID settings 217 | - Ensure container has Docker access 218 | 219 | **Web UI not accessible:** 220 | - Check port mapping: `7842:7842` 221 | - Verify firewall settings 222 | - Check Unraid network configuration 223 | 224 | **Backup generation fails:** 225 | - Check output directory permissions 226 | - Verify `/boot` mount is read-only 227 | - Review container logs for errors 228 | 229 | ### Getting Help 230 | 231 | 1. **Check logs first**: `docker logs unraid-config-guardian` 232 | 2. **GitHub Issues**: Report bugs with logs and system info 233 | 3. **Unraid Forums**: Community support 234 | 4. **Discord**: Real-time help (coming soon) 235 | 236 | ## 📈 Scaling and Performance 237 | 238 | ### Resource Usage 239 | - **Memory**: ~100-200MB typical usage 240 | - **CPU**: Minimal (spikes during backup generation) 241 | - **Disk**: Backup files typically 1-10MB each 242 | 243 | ### Optimization Tips 244 | - Run backups during low-usage hours 245 | - Use SSD for `/config` if frequent backups 246 | - Adjust `SCHEDULE` based on server change frequency 247 | - Monitor `/output` disk usage 248 | 249 | ## 🔒 Security Considerations 250 | 251 | - Container runs as non-root user (guardian) 252 | - Docker socket is read-only mounted 253 | - Sensitive environment variables are masked 254 | - No network privileges required 255 | - Flash drive mounted read-only 256 | - Security scanning included in CI/CD 257 | 258 | --- 259 | 260 | **Ready to deploy!** 🚀 The application is now production-ready with complete CI/CD automation. 261 | -------------------------------------------------------------------------------- /templates/backups.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Backups - Unraid Config Guardian{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Backup Files

9 |

Download and manage your generated backup files

10 |
11 |
12 | 13 |
14 |
15 |
16 | 17 | Backup Location: /output (container) or configured output directory 18 |
19 |
20 |
21 | 24 | 25 | Download All (ZIP) 26 | 27 | 30 |
31 |
32 | 33 | {% if backups %} 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {% for backup in backups %} 46 | 47 | 60 | 69 | 72 | 85 | 86 | {% endfor %} 87 | 88 |
FileSizeModifiedActions
48 | 49 | {{ backup.name }} 50 | {% if backup.name.endswith('.json') %} 51 | Config 52 | {% elif backup.name.endswith('.yml') %} 53 | Compose 54 | {% elif backup.name.endswith('.sh') %} 55 | Script 56 | {% elif backup.name.endswith('.md') %} 57 | Docs 58 | {% endif %} 59 | 61 | {% if backup.size < 1024 %} 62 | {{ backup.size }} B 63 | {% elif backup.size < 1024 * 1024 %} 64 | {{ "%.1f"|format(backup.size / 1024) }} KB 65 | {% else %} 66 | {{ "%.1f"|format(backup.size / (1024 * 1024)) }} MB 67 | {% endif %} 68 | 70 | {{ backup.modified.strftime('%Y-%m-%d %H:%M:%S') }} 71 | 73 | 74 | Download 75 | 76 | {% if backup.name.endswith(('.json', '.yml', '.md')) %} 77 | 83 | {% endif %} 84 |
89 |
90 | 91 | 92 |
93 |
94 |
95 |
96 |
File Descriptions
97 |
98 |
99 |
100 |
101 |
unraid-config.json
102 |

Complete system configuration including all containers, system info, and metadata.

103 | 104 |
container-templates.zip
105 |

NEW! XML templates for native Unraid restore. Extract to templates directory and use "Add Container".

106 | 107 |
docker-compose.yml
108 |

Docker Compose file for emergency fallback restore (not recommended for Unraid).

109 |
110 |
111 |
restore.sh
112 |

Automated restoration script that extracts templates and provides restore options.

113 | 114 |
README.md
115 |

Step-by-step recovery guide with both template and compose restore methods.

116 |
117 |
118 |
119 |
120 |
121 |
122 | 123 | {% else %} 124 |
125 | 126 |
No backup files found
127 |

Generate your first backup to see files here.

128 | 131 |
132 | {% endif %} 133 | 134 | 135 | 154 | 155 | {% endblock %} 156 | 157 | {% block scripts %} 158 | 184 | {% endblock %} 185 | -------------------------------------------------------------------------------- /src/config_diff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Configuration change tracking for Unraid Config Guardian. 4 | 5 | Compares current configuration with previous backups to generate change logs. 6 | """ 7 | 8 | import json 9 | import logging 10 | from datetime import datetime 11 | from pathlib import Path 12 | from typing import Any, Dict, List, Optional 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def get_previous_config(output_dir: Path) -> Optional[Dict[str, Any]]: 18 | """ 19 | Find and load the most recent previous config file. 20 | 21 | Args: 22 | output_dir: Directory where backups are stored 23 | 24 | Returns: 25 | Previous config dictionary or None if not found 26 | """ 27 | config_file = output_dir / "unraid-config.json" 28 | 29 | if not config_file.exists(): 30 | return None 31 | 32 | try: 33 | with open(config_file, "r") as f: 34 | config_data: Dict[str, Any] = json.load(f) 35 | return config_data 36 | except Exception as e: 37 | logger.warning(f"Could not read previous config: {e}") 38 | return None 39 | 40 | 41 | def compare_containers( 42 | old_containers: List[Dict], new_containers: List[Dict] 43 | ) -> Dict[str, List[str]]: 44 | """ 45 | Compare container configurations between backups. 46 | 47 | Args: 48 | old_containers: Previous container configurations 49 | new_containers: Current container configurations 50 | 51 | Returns: 52 | Dictionary with added, removed, and modified containers 53 | """ 54 | old_by_name = {c.get("name", "unknown"): c for c in old_containers} 55 | new_by_name = {c.get("name", "unknown"): c for c in new_containers} 56 | 57 | old_names = set(old_by_name.keys()) 58 | new_names = set(new_by_name.keys()) 59 | 60 | changes: Dict[str, List[str]] = {"added": [], "removed": [], "modified": []} 61 | 62 | # Find added containers 63 | for name in new_names - old_names: 64 | container = new_by_name[name] 65 | image = container.get("image", "unknown") 66 | changes["added"].append(f"+ {name} (image: {image})") 67 | 68 | # Find removed containers 69 | for name in old_names - new_names: 70 | container = old_by_name[name] 71 | image = container.get("image", "unknown") 72 | changes["removed"].append(f"- {name} (image: {image})") 73 | 74 | # Find modified containers 75 | for name in old_names & new_names: 76 | old_container = old_by_name[name] 77 | new_container = new_by_name[name] 78 | 79 | container_changes = compare_single_container(old_container, new_container) 80 | if container_changes: 81 | changes["modified"].append(f"~ {name}:") 82 | changes["modified"].extend( 83 | [f" {change}" for change in container_changes] 84 | ) 85 | 86 | return changes 87 | 88 | 89 | def compare_single_container(old: Dict, new: Dict) -> List[str]: 90 | """ 91 | Compare a single container configuration for changes. 92 | 93 | Args: 94 | old: Previous container configuration 95 | new: Current container configuration 96 | 97 | Returns: 98 | List of change descriptions 99 | """ 100 | changes = [] 101 | 102 | # Compare key fields 103 | fields_to_compare = [ 104 | ("image", "Image"), 105 | ("status", "Status"), 106 | ("restart_policy", "Restart Policy"), 107 | ] 108 | 109 | for field, display_name in fields_to_compare: 110 | old_val = old.get(field, "N/A") 111 | new_val = new.get(field, "N/A") 112 | if old_val != new_val: 113 | changes.append(f"{display_name}: {old_val} → {new_val}") 114 | 115 | # Compare ports 116 | old_ports = set(old.get("ports", [])) 117 | new_ports = set(new.get("ports", [])) 118 | if old_ports != new_ports: 119 | if old_ports - new_ports: 120 | changes.append(f"Removed ports: {', '.join(old_ports - new_ports)}") 121 | if new_ports - old_ports: 122 | changes.append(f"Added ports: {', '.join(new_ports - old_ports)}") 123 | 124 | # Compare volumes (simplified) 125 | # Volumes are stored as strings like "/host/path:/container/path" 126 | old_volumes = set(old.get("volumes", [])) 127 | new_volumes = set(new.get("volumes", [])) 128 | if old_volumes != new_volumes: 129 | if old_volumes - new_volumes: 130 | changes.append(f"Removed volumes: {', '.join(old_volumes - new_volumes)}") 131 | if new_volumes - old_volumes: 132 | changes.append(f"Added volumes: {', '.join(new_volumes - old_volumes)}") 133 | 134 | # Compare environment variables (count only, not values for security) 135 | old_env_count = len(old.get("environment", {})) 136 | new_env_count = len(new.get("environment", {})) 137 | if old_env_count != new_env_count: 138 | changes.append(f"Environment variables: {old_env_count} → {new_env_count}") 139 | 140 | return changes 141 | 142 | 143 | def compare_system_info(old_system: Dict, new_system: Dict) -> List[str]: 144 | """ 145 | Compare system information for changes. 146 | 147 | Args: 148 | old_system: Previous system information 149 | new_system: Current system information 150 | 151 | Returns: 152 | List of system changes 153 | """ 154 | changes = [] 155 | 156 | # Compare key system fields 157 | fields_to_compare = [ 158 | ("unraid_version", "Unraid Version"), 159 | ("hostname", "Hostname"), 160 | ("kernel_version", "Kernel Version"), 161 | ] 162 | 163 | for field, display_name in fields_to_compare: 164 | old_val = old_system.get(field, "N/A") 165 | new_val = new_system.get(field, "N/A") 166 | if old_val != new_val: 167 | changes.append(f"{display_name}: {old_val} → {new_val}") 168 | 169 | # Compare disk array (simplified) 170 | old_disks = len(old_system.get("disks", [])) 171 | new_disks = len(new_system.get("disks", [])) 172 | if old_disks != new_disks: 173 | changes.append(f"Disk count: {old_disks} → {new_disks}") 174 | 175 | # Compare shares (simplified) 176 | old_shares = len(old_system.get("shares", [])) 177 | new_shares = len(new_system.get("shares", [])) 178 | if old_shares != new_shares: 179 | changes.append(f"Share count: {old_shares} → {new_shares}") 180 | 181 | return changes 182 | 183 | 184 | def generate_change_log(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> str: 185 | """ 186 | Generate a human-readable change log comparing two configurations. 187 | 188 | Args: 189 | old_config: Previous configuration 190 | new_config: Current configuration 191 | 192 | Returns: 193 | Formatted change log string 194 | """ 195 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 196 | old_timestamp = old_config.get("system_info", {}).get("timestamp", "Unknown") 197 | 198 | log_lines = [ 199 | "# Unraid Configuration Changes", 200 | f"Generated: {timestamp}", 201 | f"Previous backup: {old_timestamp}", 202 | "", 203 | "## Summary", 204 | "", 205 | ] 206 | 207 | # Compare containers 208 | container_changes = compare_containers( 209 | old_config.get("containers", []), new_config.get("containers", []) 210 | ) 211 | 212 | total_changes = ( 213 | len(container_changes["added"]) 214 | + len(container_changes["removed"]) 215 | + len(container_changes["modified"]) 216 | ) 217 | 218 | if total_changes == 0: 219 | log_lines.extend( 220 | [ 221 | "✅ **No container changes detected**", 222 | "", 223 | "All containers remain unchanged since the last backup.", 224 | "", 225 | ] 226 | ) 227 | else: 228 | log_lines.append(f"📦 **{total_changes} container changes detected**") 229 | log_lines.append("") 230 | 231 | # Added containers 232 | if container_changes["added"]: 233 | log_lines.append("### New Containers") 234 | log_lines.extend(container_changes["added"]) 235 | log_lines.append("") 236 | 237 | # Removed containers 238 | if container_changes["removed"]: 239 | log_lines.append("### Removed Containers") 240 | log_lines.extend(container_changes["removed"]) 241 | log_lines.append("") 242 | 243 | # Modified containers 244 | if container_changes["modified"]: 245 | log_lines.append("### Modified Containers") 246 | log_lines.extend(container_changes["modified"]) 247 | log_lines.append("") 248 | 249 | # Compare system info 250 | system_changes = compare_system_info( 251 | old_config.get("system_info", {}), new_config.get("system_info", {}) 252 | ) 253 | 254 | if system_changes: 255 | log_lines.append("## System Changes") 256 | log_lines.append("") 257 | log_lines.extend([f"🖥️ {change}" for change in system_changes]) 258 | log_lines.append("") 259 | else: 260 | log_lines.extend( 261 | ["## System Changes", "", "✅ **No system changes detected**", ""] 262 | ) 263 | 264 | # Footer 265 | log_lines.extend(["---", "*Generated by Unraid Config Guardian*", ""]) 266 | 267 | return "\n".join(log_lines) 268 | 269 | 270 | def create_change_log(output_dir: Path, new_config: Dict[str, Any]) -> Optional[str]: 271 | """ 272 | Create a change log file by comparing with the previous backup. 273 | 274 | Args: 275 | output_dir: Directory where backups are stored 276 | new_config: Current configuration to compare 277 | 278 | Returns: 279 | Change log content or None if no previous config exists 280 | """ 281 | logger.info("🔍 Checking for configuration changes...") 282 | 283 | # Get previous configuration 284 | old_config = get_previous_config(output_dir) 285 | 286 | changes_file = output_dir / "changes.log" 287 | 288 | if not old_config: 289 | # First backup - create initial change log 290 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 291 | container_count = len(new_config.get("containers", [])) 292 | hostname = new_config.get("system_info", {}).get("hostname", "unknown") 293 | 294 | first_backup_log = f"""# Unraid Config Guardian - Change Log 295 | 296 | ## Initial Backup - {timestamp} 297 | 298 | **Server:** {hostname} 299 | **Containers:** {container_count} 300 | 301 | This is the first backup for this Unraid server. Future backups will show changes compared to this 302 | baseline. 303 | 304 | ### Summary 305 | - ✅ Initial configuration captured 306 | - ✅ {container_count} containers documented 307 | - ✅ System information recorded 308 | 309 | Future change logs will appear here when configurations are modified. 310 | """ 311 | try: 312 | changes_file.write_text(first_backup_log) 313 | logger.info("✅ Initial change log created: changes.log") 314 | logger.info(f"Change log written to: {changes_file}") 315 | except Exception as e: 316 | logger.error(f"❌ Failed to write initial change log: {e}") 317 | logger.error(f"Attempted to write to: {changes_file}") 318 | return None 319 | return first_backup_log 320 | 321 | # Generate change log for subsequent backups 322 | change_log = generate_change_log(old_config, new_config) 323 | 324 | # Write change log file 325 | try: 326 | changes_file.write_text(change_log) 327 | logger.info("✅ Change log created: changes.log") 328 | logger.info(f"Change log written to: {changes_file}") 329 | except Exception as e: 330 | logger.error(f"❌ Failed to write change log: {e}") 331 | logger.error(f"Attempted to write to: {changes_file}") 332 | return None 333 | 334 | return change_log 335 | -------------------------------------------------------------------------------- /src/web_gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Web GUI for Unraid Config Guardian 4 | FastAPI-based web interface for managing backups 5 | """ 6 | 7 | import json 8 | import logging 9 | import os 10 | import zipfile 11 | from datetime import datetime 12 | from pathlib import Path 13 | 14 | import uvicorn 15 | from fastapi import BackgroundTasks, FastAPI, Form, Request 16 | from fastapi.responses import FileResponse, HTMLResponse, JSONResponse 17 | from fastapi.templating import Jinja2Templates 18 | 19 | # Import version info 20 | from version import __version__ 21 | 22 | # Import our main application 23 | try: 24 | from unraid_config_guardian import ( 25 | create_readme, 26 | create_restore_script, 27 | generate_compose, 28 | get_containers, 29 | get_system_info, 30 | ) 31 | 32 | DOCKER_AVAILABLE = True 33 | except Exception: 34 | DOCKER_AVAILABLE = False 35 | 36 | app = FastAPI( 37 | title="Unraid Config Guardian", 38 | description="Disaster recovery documentation for Unraid servers", 39 | ) 40 | 41 | # Setup templates 42 | templates = Jinja2Templates(directory="templates") 43 | 44 | # Mock data for development when Docker isn't available 45 | MOCK_CONTAINERS = [ 46 | { 47 | "name": "plex", 48 | "image": "lscr.io/linuxserver/plex:latest", 49 | "status": "running", 50 | "ports": ["32400:32400/tcp"], 51 | "volumes": ["/mnt/user/appdata/plex:/config", "/mnt/user/media:/media"], 52 | "environment": {"PUID": "99", "PGID": "100", "VERSION": "docker"}, 53 | }, 54 | { 55 | "name": "nginx", 56 | "image": "nginx:latest", 57 | "status": "running", 58 | "ports": ["80:80/tcp", "443:443/tcp"], 59 | "volumes": ["/mnt/user/appdata/nginx:/etc/nginx"], 60 | "environment": {"TZ": "America/New_York"}, 61 | }, 62 | { 63 | "name": "unifi-controller", 64 | "image": "lscr.io/linuxserver/unifi-controller:latest", 65 | "status": "exited", 66 | "ports": ["8080:8080/tcp", "8443:8443/tcp"], 67 | "volumes": ["/mnt/user/appdata/unifi:/config"], 68 | "environment": {"PUID": "99", "PGID": "100", "MONGO_PASSWORD": "***MASKED***"}, 69 | }, 70 | ] 71 | 72 | MOCK_SYSTEM_INFO = { 73 | "timestamp": datetime.now().isoformat(), 74 | "hostname": "unraid-server", 75 | "unraid_version": "6.12.4", 76 | "guardian_version": __version__, 77 | } 78 | 79 | # Global state for background tasks 80 | background_status = { 81 | "running": False, 82 | "progress": 0, 83 | "message": "Ready", 84 | "last_run": None, 85 | "last_error": None, 86 | } 87 | 88 | 89 | def get_containers_safe(): 90 | """Get containers with fallback to mock data.""" 91 | if not DOCKER_AVAILABLE: 92 | return MOCK_CONTAINERS 93 | try: 94 | return get_containers() 95 | except Exception as e: 96 | print(f"Docker not available, using mock data: {e}") 97 | return MOCK_CONTAINERS 98 | 99 | 100 | def get_system_info_safe(): 101 | """Get system info with fallback to mock data.""" 102 | if not DOCKER_AVAILABLE: 103 | return MOCK_SYSTEM_INFO 104 | try: 105 | return get_system_info() 106 | except Exception: 107 | return MOCK_SYSTEM_INFO 108 | 109 | 110 | @app.get("/", response_class=HTMLResponse) 111 | async def dashboard(request: Request): 112 | """Main dashboard.""" 113 | # Get basic system info 114 | try: 115 | system_info = get_system_info_safe() 116 | containers = get_containers_safe() 117 | 118 | stats = { 119 | "total_containers": len(containers), 120 | "running_containers": len( 121 | [c for c in containers if c["status"] == "running"] 122 | ), 123 | "system_info": system_info, 124 | "last_backup": get_last_backup_info(), 125 | "status": background_status, 126 | } 127 | except Exception as e: 128 | stats = {"error": str(e), "status": background_status} 129 | 130 | return templates.TemplateResponse( 131 | "dashboard.html", {"request": request, "stats": stats} 132 | ) 133 | 134 | 135 | @app.get("/containers", response_class=HTMLResponse) 136 | async def containers_page(request: Request): 137 | """Containers overview page.""" 138 | try: 139 | containers = get_containers_safe() 140 | system_info = get_system_info_safe() 141 | stats = {"system_info": system_info} 142 | return templates.TemplateResponse( 143 | "containers.html", 144 | {"request": request, "containers": containers, "stats": stats}, 145 | ) 146 | except Exception as e: 147 | return templates.TemplateResponse( 148 | "error.html", {"request": request, "error": str(e)} 149 | ) 150 | 151 | 152 | @app.get("/api/containers") 153 | async def api_containers(): 154 | """API endpoint for container data.""" 155 | try: 156 | containers = get_containers_safe() 157 | return {"containers": containers} 158 | except Exception as e: 159 | return JSONResponse(status_code=500, content={"error": str(e)}) 160 | 161 | 162 | @app.post("/api/backup/start") 163 | async def start_backup( 164 | background_tasks: BackgroundTasks, output_dir: str = Form("/output") 165 | ): 166 | """Start backup process.""" 167 | if background_status["running"]: 168 | return JSONResponse( 169 | status_code=409, content={"error": "Backup already running"} 170 | ) 171 | 172 | background_tasks.add_task(run_backup, output_dir) 173 | return {"message": "Backup started", "status": "running"} 174 | 175 | 176 | @app.get("/api/backup/status") 177 | async def backup_status(): 178 | """Get backup status.""" 179 | return background_status 180 | 181 | 182 | @app.get("/api/system") 183 | async def api_system(): 184 | """API endpoint for system information.""" 185 | try: 186 | system_info = get_system_info_safe() 187 | return {"system": system_info} 188 | except Exception as e: 189 | return JSONResponse(status_code=500, content={"error": str(e)}) 190 | 191 | 192 | @app.get("/backups") 193 | async def list_backups(request: Request): 194 | """List available backups.""" 195 | output_dir = Path(os.getenv("OUTPUT_DIR", "/output")) 196 | backups = [] 197 | 198 | if output_dir.exists(): 199 | for item in output_dir.iterdir(): 200 | if item.is_file(): 201 | backups.append( 202 | { 203 | "name": item.name, 204 | "size": item.stat().st_size, 205 | "modified": datetime.fromtimestamp(item.stat().st_mtime), 206 | "path": str(item), 207 | } 208 | ) 209 | 210 | backups.sort(key=lambda x: x["modified"], reverse=True) # type: ignore 211 | 212 | system_info = get_system_info_safe() 213 | stats = {"system_info": system_info} 214 | 215 | return templates.TemplateResponse( 216 | "backups.html", {"request": request, "backups": backups, "stats": stats} 217 | ) 218 | 219 | 220 | @app.get("/download/{filename}") 221 | async def download_file(filename: str): 222 | """Download backup file.""" 223 | output_dir = Path(os.getenv("OUTPUT_DIR", "/output")) 224 | file_path = output_dir / filename 225 | 226 | if file_path.exists() and file_path.is_file(): 227 | return FileResponse(file_path, filename=filename) 228 | 229 | return JSONResponse(status_code=404, content={"error": "File not found"}) 230 | 231 | 232 | @app.get("/download-all") 233 | async def download_all_files(): 234 | """Download all backup files as a zip.""" 235 | output_dir = Path(os.getenv("OUTPUT_DIR", "/output")) 236 | 237 | # Define the backup files to include 238 | backup_files = [ 239 | "unraid-config.json", 240 | "docker-compose.yml", 241 | "restore.sh", 242 | "README.md", 243 | "container-templates.zip", 244 | ] 245 | 246 | # Check if any backup files exist 247 | existing_files = [f for f in backup_files if (output_dir / f).exists()] 248 | 249 | if not existing_files: 250 | return JSONResponse(status_code=404, content={"error": "No backup files found"}) 251 | 252 | # Create temporary zip file 253 | import tempfile 254 | 255 | temp_dir = Path(tempfile.gettempdir()) 256 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 257 | zip_filename = f"unraid-backup_{timestamp}.zip" 258 | zip_path = temp_dir / zip_filename 259 | 260 | try: 261 | with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: 262 | for filename in existing_files: 263 | file_path = output_dir / filename 264 | if file_path.exists(): 265 | zipf.write(file_path, filename) 266 | 267 | return FileResponse( 268 | zip_path, filename=zip_filename, media_type="application/zip" 269 | ) 270 | except Exception as e: 271 | # Clean up on error 272 | if zip_path.exists(): 273 | zip_path.unlink() 274 | return JSONResponse( 275 | status_code=500, content={"error": f"Failed to create zip: {str(e)}"} 276 | ) 277 | 278 | 279 | async def run_backup(output_dir: str): 280 | """Run backup in background.""" 281 | background_status.update( 282 | { 283 | "running": True, 284 | "progress": 0, 285 | "message": "Starting backup...", 286 | "last_error": None, 287 | } 288 | ) 289 | 290 | try: 291 | # Step 1: Collect containers 292 | background_status.update( 293 | {"progress": 25, "message": "Collecting container information..."} 294 | ) 295 | containers = get_containers_safe() 296 | 297 | # Step 2: Get system info 298 | background_status.update( 299 | {"progress": 50, "message": "Collecting system information..."} 300 | ) 301 | system_info = get_system_info_safe() 302 | 303 | # Step 3: Generate compose 304 | background_status.update( 305 | {"progress": 75, "message": "Generating docker-compose..."} 306 | ) 307 | if DOCKER_AVAILABLE: 308 | compose = generate_compose(containers) 309 | else: 310 | # Mock compose for development 311 | compose = { 312 | "version": "3.8", 313 | "services": { 314 | "plex": { 315 | "image": "lscr.io/linuxserver/plex:latest", 316 | "container_name": "plex", 317 | "restart": "unless-stopped", 318 | "ports": ["32400:32400"], 319 | "volumes": ["/mnt/user/appdata/plex:/config"], 320 | "environment": {"PUID": "99", "PGID": "100"}, 321 | } 322 | }, 323 | } 324 | 325 | # Step 4: Collect templates 326 | background_status.update( 327 | {"progress": 80, "message": "Collecting XML templates..."} 328 | ) 329 | templates = [] 330 | if DOCKER_AVAILABLE: 331 | from unraid_config_guardian import get_container_templates 332 | 333 | templates = get_container_templates() 334 | 335 | # Step 5: Write files 336 | background_status.update({"progress": 90, "message": "Writing backup files..."}) 337 | 338 | output_path = Path(output_dir) 339 | output_path.mkdir(parents=True, exist_ok=True, mode=0o755) 340 | # Set permissions explicitly for Unraid compatibility 341 | os.chmod(output_path, 0o755) 342 | 343 | # Create complete config 344 | config = { 345 | "system_info": system_info, 346 | "containers": containers, 347 | "templates": templates, 348 | } 349 | 350 | # Generate change log if available 351 | change_log_content = None 352 | if DOCKER_AVAILABLE: 353 | try: 354 | from config_diff import create_change_log 355 | 356 | logging.info("Generating configuration change log...") 357 | change_log_content = create_change_log(output_path, config) 358 | if change_log_content: 359 | logging.info("Change log generated successfully") 360 | else: 361 | logging.info("No change log generated (first backup or no changes)") 362 | except ImportError: 363 | logging.warning( 364 | "Change log functionality not available (config_diff module not found)" 365 | ) 366 | except Exception as e: 367 | logging.error(f"Error generating change log: {e}") 368 | 369 | # Write files 370 | import yaml 371 | 372 | if DOCKER_AVAILABLE: 373 | restore_script = create_restore_script(system_info) 374 | readme = create_readme(system_info, len(containers)) 375 | else: 376 | # Mock scripts for development 377 | restore_script = """#!/bin/bash 378 | echo "🔄 Restoring Unraid setup (DEVELOPMENT MODE)..." 379 | docker-compose up -d 380 | echo "✅ Restore complete!" 381 | """ 382 | readme = f"""# Unraid Backup Documentation (DEVELOPMENT MODE) 383 | 384 | **Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} 385 | **Server:** {system_info['hostname']} 386 | **Containers:** {len(containers)} 387 | 388 | This is a development/demo backup. 389 | """ 390 | 391 | files = { 392 | "unraid-config.json": json.dumps(config, indent=2), 393 | "docker-compose.yml": ( 394 | f"# Generated by Config Guardian\n# {system_info['timestamp']}\n\n" 395 | + yaml.dump(compose, default_flow_style=False) 396 | ), 397 | "restore.sh": restore_script, 398 | "README.md": readme, 399 | } 400 | 401 | for filename, content in files.items(): 402 | (output_path / filename).write_text(content) 403 | 404 | # Make restore script executable 405 | os.chmod(output_path / "restore.sh", 0o755) 406 | 407 | # Create templates zip if templates exist 408 | if templates and DOCKER_AVAILABLE: 409 | background_status.update( 410 | {"progress": 95, "message": "Creating templates zip..."} 411 | ) 412 | from unraid_config_guardian import create_templates_zip 413 | 414 | zip_result = create_templates_zip(templates, output_path) 415 | if zip_result: 416 | files["container-templates.zip"] = "Binary file" 417 | 418 | background_status.update( 419 | { 420 | "running": False, 421 | "progress": 100, 422 | "message": f"Backup completed! Generated {len(files)} files.", 423 | "last_run": datetime.now().isoformat(), 424 | } 425 | ) 426 | 427 | except Exception as e: 428 | background_status.update( 429 | { 430 | "running": False, 431 | "progress": 0, 432 | "message": "Backup failed", 433 | "last_error": str(e), 434 | } 435 | ) 436 | 437 | 438 | def get_last_backup_info(): 439 | """Get information about the last backup.""" 440 | output_dir = Path(os.getenv("OUTPUT_DIR", "/output")) 441 | config_file = output_dir / "unraid-config.json" 442 | 443 | if config_file.exists(): 444 | try: 445 | with open(config_file) as f: 446 | config = json.load(f) 447 | 448 | backup_info = { 449 | "timestamp": config.get("system_info", {}).get("timestamp"), 450 | "containers": len(config.get("containers", [])), 451 | "size": config_file.stat().st_size, 452 | "has_changes": False, 453 | "changes_summary": "No changes detected", 454 | } 455 | 456 | # Check for changes.log file 457 | changes_file = output_dir / "changes.log" 458 | if changes_file.exists(): 459 | try: 460 | changes_content = changes_file.read_text() 461 | backup_info["has_changes"] = True 462 | 463 | # Extract summary from changes file 464 | lines = changes_content.split("\n") 465 | for line in lines: 466 | if "changes detected" in line: 467 | backup_info["changes_summary"] = line.strip().replace( 468 | "**", "" 469 | ) 470 | break 471 | else: 472 | backup_info[ 473 | "changes_summary" 474 | ] = "Changes detected - see changes.log" 475 | 476 | except Exception: 477 | backup_info[ 478 | "changes_summary" 479 | ] = "Changes file exists but couldn't be read" 480 | 481 | return backup_info 482 | except Exception: 483 | pass 484 | 485 | return None 486 | 487 | 488 | def main(): 489 | """Run the web server.""" 490 | port = int(os.getenv("WEB_PORT", 7842)) 491 | host = os.getenv("WEB_HOST", "0.0.0.0") 492 | 493 | print(f"🌐 Starting Unraid Config Guardian Web GUI on http://{host}:{port}") 494 | print("🔗 Access via: http://your-unraid-ip:7842") 495 | 496 | uvicorn.run("web_gui:app", host=host, port=port, reload=False, access_log=True) 497 | 498 | 499 | if __name__ == "__main__": 500 | main() 501 | -------------------------------------------------------------------------------- /src/web_gui_dev.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Development version of Web GUI that handles Docker connection gracefully 4 | """ 5 | 6 | import asyncio 7 | import json 8 | import logging 9 | import os 10 | import zipfile 11 | from datetime import datetime 12 | from pathlib import Path 13 | 14 | import uvicorn 15 | from fastapi import BackgroundTasks, FastAPI, Form, Request 16 | from fastapi.responses import FileResponse, HTMLResponse, JSONResponse 17 | from fastapi.templating import Jinja2Templates 18 | 19 | # Import version info 20 | from version import __version__ 21 | 22 | # Mock container data for development when Docker isn't available 23 | MOCK_CONTAINERS = [ 24 | { 25 | "name": "plex", 26 | "image": "lscr.io/linuxserver/plex:latest", 27 | "status": "running", 28 | "ports": ["32400:32400/tcp"], 29 | "volumes": ["/mnt/user/appdata/plex:/config", "/mnt/user/media:/media"], 30 | "environment": {"PUID": "99", "PGID": "100", "VERSION": "docker"}, 31 | }, 32 | { 33 | "name": "nginx", 34 | "image": "nginx:latest", 35 | "status": "running", 36 | "ports": ["80:80/tcp", "443:443/tcp"], 37 | "volumes": ["/mnt/user/appdata/nginx:/etc/nginx"], 38 | "environment": {"TZ": "America/New_York"}, 39 | }, 40 | { 41 | "name": "unifi-controller", 42 | "image": "lscr.io/linuxserver/unifi-controller:latest", 43 | "status": "exited", 44 | "ports": ["8080:8080/tcp", "8443:8443/tcp"], 45 | "volumes": ["/mnt/user/appdata/unifi:/config"], 46 | "environment": {"PUID": "99", "PGID": "100", "MONGO_PASSWORD": "***MASKED***"}, 47 | }, 48 | ] 49 | 50 | MOCK_SYSTEM_INFO = { 51 | "timestamp": datetime.now().isoformat(), 52 | "hostname": "unraid-server", 53 | "unraid_version": "6.12.4", 54 | "guardian_version": __version__, 55 | } 56 | 57 | app = FastAPI( 58 | title="Unraid Config Guardian", 59 | description="Disaster recovery documentation for Unraid servers", 60 | ) 61 | 62 | # Setup templates 63 | templates = Jinja2Templates(directory="templates") 64 | 65 | # Global state for background tasks 66 | background_status = { 67 | "running": False, 68 | "progress": 0, 69 | "message": "Ready", 70 | "last_run": None, 71 | "last_error": None, 72 | } 73 | 74 | 75 | def get_containers_safe(): 76 | """Get containers with fallback to mock data.""" 77 | try: 78 | # Try to import and use real Docker client 79 | from unraid_config_guardian import get_containers 80 | 81 | return get_containers() 82 | except Exception as e: 83 | print(f"Docker not available, using mock data: {e}") 84 | return MOCK_CONTAINERS 85 | 86 | 87 | def get_system_info_safe(): 88 | """Get system info with fallback to mock data.""" 89 | try: 90 | from unraid_config_guardian import get_system_info 91 | 92 | return get_system_info() 93 | except Exception: 94 | return MOCK_SYSTEM_INFO 95 | 96 | 97 | @app.get("/", response_class=HTMLResponse) 98 | async def dashboard(request: Request): 99 | """Main dashboard.""" 100 | try: 101 | system_info = get_system_info_safe() 102 | containers = get_containers_safe() 103 | 104 | stats = { 105 | "total_containers": len(containers), 106 | "running_containers": len( 107 | [c for c in containers if c["status"] == "running"] 108 | ), 109 | "system_info": system_info, 110 | "last_backup": get_last_backup_info(), 111 | "status": background_status, 112 | } 113 | except Exception as e: 114 | stats = {"error": str(e), "status": background_status} 115 | 116 | return templates.TemplateResponse( 117 | "dashboard.html", {"request": request, "stats": stats} 118 | ) 119 | 120 | 121 | @app.get("/containers", response_class=HTMLResponse) 122 | async def containers_page(request: Request): 123 | """Containers overview page.""" 124 | try: 125 | containers = get_containers_safe() 126 | stats = {"system_info": MOCK_SYSTEM_INFO} 127 | return templates.TemplateResponse( 128 | "containers.html", 129 | {"request": request, "containers": containers, "stats": stats}, 130 | ) 131 | except Exception as e: 132 | return templates.TemplateResponse( 133 | "error.html", {"request": request, "error": str(e)} 134 | ) 135 | 136 | 137 | @app.get("/api/containers") 138 | async def api_containers(): 139 | """API endpoint for container data.""" 140 | try: 141 | containers = get_containers_safe() 142 | return {"containers": containers} 143 | except Exception as e: 144 | return JSONResponse(status_code=500, content={"error": str(e)}) 145 | 146 | 147 | @app.post("/api/backup/start") 148 | async def start_backup( 149 | background_tasks: BackgroundTasks, output_dir: str = Form("/output") 150 | ): 151 | """Start backup process.""" 152 | if background_status["running"]: 153 | return JSONResponse( 154 | status_code=409, content={"error": "Backup already running"} 155 | ) 156 | 157 | background_tasks.add_task(run_backup_mock, output_dir) 158 | return {"message": "Backup started", "status": "running"} 159 | 160 | 161 | @app.get("/api/backup/status") 162 | async def backup_status(): 163 | """Get backup status.""" 164 | return background_status 165 | 166 | 167 | @app.get("/api/system") 168 | async def api_system(): 169 | """API endpoint for system information.""" 170 | try: 171 | system_info = get_system_info_safe() 172 | return {"system": system_info} 173 | except Exception as e: 174 | return JSONResponse(status_code=500, content={"error": str(e)}) 175 | 176 | 177 | @app.get("/backups") 178 | async def list_backups(request: Request): 179 | """List available backups.""" 180 | output_dir = Path(os.getenv("OUTPUT_DIR", "./output")) 181 | backups = [] 182 | 183 | if output_dir.exists(): 184 | for item in output_dir.iterdir(): 185 | if item.is_file(): 186 | backups.append( 187 | { 188 | "name": item.name, 189 | "size": item.stat().st_size, 190 | "modified": datetime.fromtimestamp(item.stat().st_mtime), 191 | "path": str(item), 192 | } 193 | ) 194 | 195 | backups.sort(key=lambda x: x["modified"], reverse=True) # type: ignore 196 | 197 | stats = {"system_info": MOCK_SYSTEM_INFO} 198 | 199 | return templates.TemplateResponse( 200 | "backups.html", {"request": request, "backups": backups, "stats": stats} 201 | ) 202 | 203 | 204 | @app.get("/download/{filename}") 205 | async def download_file(filename: str): 206 | """Download backup file.""" 207 | output_dir = Path(os.getenv("OUTPUT_DIR", "./output")) 208 | file_path = output_dir / filename 209 | 210 | if file_path.exists() and file_path.is_file(): 211 | return FileResponse(file_path, filename=filename) 212 | 213 | return JSONResponse(status_code=404, content={"error": "File not found"}) 214 | 215 | 216 | @app.get("/download-all") 217 | async def download_all_files(): 218 | """Download all backup files as a zip.""" 219 | output_dir = Path(os.getenv("OUTPUT_DIR", "./output")) 220 | 221 | # Define the backup files to include 222 | backup_files = [ 223 | "unraid-config.json", 224 | "docker-compose.yml", 225 | "restore.sh", 226 | "README.md", 227 | "container-templates.zip", 228 | ] 229 | 230 | # Check if any backup files exist 231 | existing_files = [f for f in backup_files if (output_dir / f).exists()] 232 | 233 | if not existing_files: 234 | return JSONResponse(status_code=404, content={"error": "No backup files found"}) 235 | 236 | # Create temporary zip file 237 | import tempfile 238 | 239 | temp_dir = Path(tempfile.gettempdir()) 240 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 241 | zip_filename = f"unraid-backup_{timestamp}.zip" 242 | zip_path = temp_dir / zip_filename 243 | 244 | try: 245 | with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: 246 | for filename in existing_files: 247 | file_path = output_dir / filename 248 | if file_path.exists(): 249 | zipf.write(file_path, filename) 250 | 251 | return FileResponse( 252 | zip_path, filename=zip_filename, media_type="application/zip" 253 | ) 254 | except Exception as e: 255 | # Clean up on error 256 | if zip_path.exists(): 257 | zip_path.unlink() 258 | return JSONResponse( 259 | status_code=500, content={"error": f"Failed to create zip: {str(e)}"} 260 | ) 261 | 262 | 263 | async def run_backup_mock(output_dir: str): 264 | """Mock backup process for development.""" 265 | 266 | background_status.update( 267 | { 268 | "running": True, 269 | "progress": 0, 270 | "message": "Starting backup...", 271 | "last_error": None, 272 | } 273 | ) 274 | 275 | try: 276 | # Simulate backup process 277 | await asyncio.sleep(1) 278 | background_status.update( 279 | {"progress": 25, "message": "Collecting container information..."} 280 | ) 281 | 282 | await asyncio.sleep(1) 283 | background_status.update( 284 | {"progress": 40, "message": "Collecting system information..."} 285 | ) 286 | 287 | await asyncio.sleep(1) 288 | background_status.update( 289 | {"progress": 60, "message": "Collecting XML templates..."} 290 | ) 291 | 292 | await asyncio.sleep(1) 293 | background_status.update( 294 | {"progress": 80, "message": "Generating docker-compose..."} 295 | ) 296 | 297 | await asyncio.sleep(1) 298 | background_status.update({"progress": 90, "message": "Writing backup files..."}) 299 | 300 | # Create mock backup files 301 | output_path = Path(output_dir) 302 | output_path.mkdir(parents=True, exist_ok=True, mode=0o755) 303 | # Set permissions explicitly for Unraid compatibility 304 | os.chmod(output_path, 0o755) 305 | 306 | # Generate change log if available 307 | change_log_content = None 308 | try: 309 | from config_diff import create_change_log 310 | 311 | logging.info("Generating configuration change log...") 312 | config = {"system_info": MOCK_SYSTEM_INFO, "containers": MOCK_CONTAINERS} 313 | change_log_content = create_change_log(output_path, config) 314 | if change_log_content: 315 | logging.info("Change log generated successfully") 316 | else: 317 | logging.info("No change log generated (first backup or no changes)") 318 | except ImportError: 319 | logging.warning( 320 | "Change log functionality not available (config_diff module not found)" 321 | ) 322 | except Exception as e: 323 | logging.error(f"Error generating change log: {e}") 324 | 325 | # Mock files 326 | files = { 327 | "unraid-config.json": json.dumps( 328 | {"system_info": MOCK_SYSTEM_INFO, "containers": MOCK_CONTAINERS}, 329 | indent=2, 330 | ), 331 | "docker-compose.yml": """# Generated by Unraid Config Guardian 332 | version: '3.8' 333 | 334 | services: 335 | plex: 336 | image: lscr.io/linuxserver/plex:latest 337 | container_name: plex 338 | restart: unless-stopped 339 | ports: 340 | - "32400:32400" 341 | volumes: 342 | - /mnt/user/appdata/plex:/config 343 | - /mnt/user/media:/media 344 | environment: 345 | - PUID=99 346 | - PGID=100 347 | """, 348 | "restore.sh": """#!/bin/bash 349 | # Unraid Config Guardian - Restore Script (Mock Development Version) 350 | 351 | echo "🔄 Restoring Unraid setup..." 352 | 353 | # Function to restore XML templates 354 | restore_templates() { 355 | if [ -f "container-templates.zip" ]; then 356 | echo "📋 Restoring XML templates..." 357 | mkdir -p /boot/config/plugins/dockerMan/templates-user 358 | unzip -o container-templates.zip -d /boot/config/plugins/dockerMan/templates-user 359 | if [ $? -eq 0 ]; then 360 | echo "✅ XML templates restored" 361 | echo "ℹ️ Templates will appear in 'Add Container' dropdown" 362 | else 363 | echo "❌ Failed to extract templates" 364 | fi 365 | else 366 | echo "ℹ️ No container-templates.zip found - skipping template restore" 367 | fi 368 | } 369 | 370 | # Main restore process 371 | echo "📋 UNRAID RESTORE OPTIONS:" 372 | echo "Option 1: Restore XML Templates (Recommended)" 373 | restore_templates 374 | 375 | echo "" 376 | echo "Option 2: Docker-Compose Fallback (Emergency only)" 377 | if [ -f "docker-compose.yml" ]; then 378 | if command -v docker-compose &> /dev/null || docker compose version &> /dev/null; then 379 | echo "💡 Run: docker-compose up -d (or docker compose up -d)" 380 | else 381 | echo "❌ Docker Compose not available" 382 | fi 383 | fi 384 | 385 | echo "" 386 | echo "✅ Restore process complete!" 387 | echo "📋 Next: Go to Docker tab → Add Container → Select templates" 388 | """, 389 | "README.md": """# Unraid Backup Documentation 390 | 391 | **Generated:** Mock Development Data 392 | **Server:** unraid-server 393 | **Containers:** 3 394 | 395 | ## Quick Recovery (Recommended: Unraid Templates) 396 | 397 | 1. Install fresh Unraid 398 | 2. Restore flash drive from backup 399 | 3. Set up disk array 400 | 4. Run: `bash restore.sh` (restores XML templates) 401 | 5. Go to Docker tab → Add Container → Select your templates 402 | 6. Configure paths and restore appdata from backup 403 | 404 | ## Files 405 | 406 | - `unraid-config.json` - Complete system configuration 407 | - `container-templates.zip` - XML templates for native Unraid restore 408 | - `docker-compose.yml` - Fallback container definitions 409 | - `restore.sh` - Automated restoration script 410 | - `README.md` - This file 411 | 412 | ## Restore Methods 413 | 414 | ### Method 1: Native Unraid Templates (Recommended) 415 | ```bash 416 | bash restore.sh # Extracts templates to /boot/config/plugins/dockerMan/templates-user 417 | ``` 418 | Then use Unraid WebUI to add containers from templates. 419 | 420 | ### Method 2: Docker Compose (Emergency Fallback) 421 | ```bash 422 | docker-compose up -d # Or: docker compose up -d 423 | ``` 424 | Note: Bypasses Unraid's container management system. 425 | 426 | Keep this documentation safe and test your restore process! 427 | """, 428 | } 429 | 430 | for filename, content in files.items(): 431 | (output_path / filename).write_text(content) 432 | 433 | # Create a mock container-templates.zip with sample XML templates 434 | template_zip_path = output_path / "container-templates.zip" 435 | try: 436 | with zipfile.ZipFile(template_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: 437 | # Mock Plex template 438 | plex_template = """ 439 | 440 | plex 441 | lscr.io/linuxserver/plex:latest 442 | https://lscr.io 443 | bridge 444 | 445 | bash 446 | false 447 | https://forums.unraid.net/topic/40463-support-linuxserver-io-plex-media-server/ 448 | https://www.plex.tv/ 449 | Plex organizes video, music and photos from personal media libraries. 450 | MediaServer:Video MediaServer:Music MediaServer:Photos 451 | http://[IP]:[PORT:32400]/web 452 | https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/plex.xml 453 | https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/plex-icon.png 454 | 455 | 456 | 457 | 1640995200 458 | Donations 459 | https://www.linuxserver.io/donate 460 | 461 | 32400 464 | 32400 467 | /mnt/user/appdata/plex 471 | /mnt/user/media 474 | 99 477 | 100 480 | docker 483 | """ 484 | zipf.writestr("plex.xml", plex_template) 485 | 486 | # Mock Nginx template 487 | nginx_template = """ 488 | 489 | nginx 490 | nginx:latest 491 | https://hub.docker.com 492 | bridge 493 | 494 | bash 495 | false 496 | https://forums.unraid.net/ 497 | https://nginx.org/ 498 | Nginx web server 499 | Network:Web 500 | http://[IP]:[PORT:80]/ 501 | https://raw.githubusercontent.com/A75G/docker-templates/master/templates/icons/nginx.png 502 | 8080 505 | /mnt/user/appdata/nginx 509 | """ 510 | zipf.writestr("nginx.xml", nginx_template) 511 | 512 | file_count = len(files) + 1 # Include the zip file 513 | except Exception as e: 514 | print(f"Failed to create mock template zip: {e}") 515 | file_count = len(files) 516 | 517 | background_status.update( 518 | { 519 | "running": False, 520 | "progress": 100, 521 | "message": f"Mock backup completed! Generated {file_count} files.", 522 | "last_run": datetime.now().isoformat(), 523 | } 524 | ) 525 | 526 | except Exception as e: 527 | background_status.update( 528 | { 529 | "running": False, 530 | "progress": 0, 531 | "message": "Backup failed", 532 | "last_error": str(e), 533 | } 534 | ) 535 | 536 | 537 | def get_last_backup_info(): 538 | """Get information about the last backup.""" 539 | output_dir = Path(os.getenv("OUTPUT_DIR", "./output")) 540 | config_file = output_dir / "unraid-config.json" 541 | 542 | if config_file.exists(): 543 | try: 544 | with open(config_file) as f: 545 | config = json.load(f) 546 | 547 | backup_info = { 548 | "timestamp": config.get("system_info", {}).get("timestamp"), 549 | "containers": len(config.get("containers", [])), 550 | "size": config_file.stat().st_size, 551 | "has_changes": False, 552 | "changes_summary": "No changes detected", 553 | } 554 | 555 | # Check for changes.log file 556 | changes_file = output_dir / "changes.log" 557 | if changes_file.exists(): 558 | try: 559 | changes_content = changes_file.read_text() 560 | backup_info["has_changes"] = True 561 | 562 | # Extract summary from changes file 563 | lines = changes_content.split("\n") 564 | for line in lines: 565 | if "changes detected" in line: 566 | backup_info["changes_summary"] = line.strip().replace( 567 | "**", "" 568 | ) 569 | break 570 | else: 571 | backup_info[ 572 | "changes_summary" 573 | ] = "Changes detected - see changes.log" 574 | 575 | except Exception: 576 | backup_info[ 577 | "changes_summary" 578 | ] = "Changes file exists but couldn't be read" 579 | 580 | return backup_info 581 | except Exception: 582 | pass 583 | 584 | return None 585 | 586 | 587 | def main(): 588 | """Run the web server.""" 589 | port = int(os.getenv("WEB_PORT", 7842)) 590 | host = os.getenv("WEB_HOST", "0.0.0.0") 591 | 592 | print("🌐 Starting Unraid Config Guardian Web GUI (Development Mode)") 593 | print(f"🔗 Access via: http://localhost:{port}") 594 | print("📋 Note: Using mock data for development") 595 | 596 | uvicorn.run("web_gui_dev:app", host=host, port=port, reload=False, access_log=True) 597 | 598 | 599 | if __name__ == "__main__": 600 | main() 601 | -------------------------------------------------------------------------------- /src/unraid_config_guardian.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Unraid Config Guardian 4 | Simple script to document your Unraid setup for disaster recovery 5 | 6 | Author: Stephon Parker (stephondoestech) 7 | """ 8 | 9 | import argparse 10 | import json 11 | import logging 12 | import os 13 | import subprocess 14 | import zipfile 15 | from datetime import datetime 16 | from pathlib import Path 17 | from typing import List 18 | 19 | import yaml 20 | 21 | import docker 22 | from config_diff import create_change_log 23 | from version import __version__ 24 | 25 | # Removed unused imports: Any, Dict, List 26 | 27 | 28 | def get_containers(): 29 | """Get all Docker containers and their info.""" 30 | try: 31 | # For local testing/CI, use direct Docker socket 32 | if os.getenv("PYTEST_CURRENT_TEST"): 33 | client = docker.from_env() 34 | else: 35 | # Priority 1: Use DOCKER_HOST if set (recommended for socket proxy) 36 | docker_host = os.getenv("DOCKER_HOST") 37 | 38 | if docker_host: 39 | # DOCKER_HOST is set, use it 40 | client = docker.DockerClient(base_url=docker_host) 41 | else: 42 | # Priority 2: Fall back to DOCKER_SOCK_PATH for backward compatibility 43 | docker_sock_path = os.getenv("DOCKER_SOCK_PATH") 44 | 45 | if docker_sock_path: 46 | # Legacy variable - use unix socket 47 | client = docker.DockerClient(base_url=f"unix://{docker_sock_path}") 48 | else: 49 | # Priority 3: Default to socket proxy 50 | client = docker.DockerClient( 51 | base_url="tcp://docker-socket-proxy:2375" 52 | ) 53 | 54 | # Test Docker connectivity 55 | client.ping() 56 | except Exception as e: 57 | logging.error(f"Cannot connect to Docker daemon: {e}") 58 | logging.error("Make sure Docker Socket Proxy is running and accessible") 59 | docker_host_env = os.getenv("DOCKER_HOST") 60 | docker_sock_path_env = os.getenv("DOCKER_SOCK_PATH") 61 | if docker_host_env: 62 | logging.error(f"Current DOCKER_HOST: {docker_host_env}") 63 | elif docker_sock_path_env: 64 | logging.error(f"Current DOCKER_SOCK_PATH: {docker_sock_path_env}") 65 | else: 66 | logging.error("Using default: tcp://docker-socket-proxy:2375") 67 | raise 68 | 69 | containers = [] 70 | 71 | for container in client.containers.list(all=True): 72 | # Extract basic info 73 | try: 74 | # Try to get image info, handle missing images gracefully 75 | image_name = "unknown" 76 | try: 77 | if container.image and container.image.tags: 78 | image_name = container.image.tags[0] 79 | elif hasattr(container, "attrs") and container.attrs.get( 80 | "Config", {} 81 | ).get("Image"): 82 | # Fallback to image name from container config 83 | image_name = container.attrs["Config"]["Image"] 84 | except ( 85 | docker.errors.ImageNotFound, 86 | docker.errors.NotFound, 87 | AttributeError, 88 | ): 89 | # Image was deleted or not found, try to get from container attrs 90 | if hasattr(container, "attrs") and container.attrs.get( 91 | "Config", {} 92 | ).get("Image"): 93 | image_name = container.attrs["Config"]["Image"] 94 | else: 95 | image_name = f"missing-image-{container.id[:12]}" 96 | 97 | info = { 98 | "name": container.name, 99 | "image": image_name, 100 | "status": container.status, 101 | "ports": [], 102 | "volumes": [], 103 | "environment": {}, 104 | } 105 | except Exception as e: 106 | logging.warning(f"Error processing container {container.name}: {e}") 107 | continue 108 | 109 | # Get ports 110 | try: 111 | ports = container.attrs.get("NetworkSettings", {}).get("Ports") or {} 112 | for container_port, host_bindings in ports.items(): 113 | if host_bindings: 114 | host_port = host_bindings[0]["HostPort"] 115 | info["ports"].append(f"{host_port}:{container_port}") 116 | except (KeyError, AttributeError, IndexError) as e: 117 | logging.warning(f"Error getting ports for {container.name}: {e}") 118 | 119 | # Get volumes 120 | try: 121 | for mount in container.attrs.get("Mounts") or []: 122 | if mount.get("Type") == "bind": 123 | source = mount.get("Source", "unknown") 124 | destination = mount.get("Destination", "unknown") 125 | info["volumes"].append(f"{source}:{destination}") 126 | except (KeyError, AttributeError) as e: 127 | logging.warning(f"Error getting volumes for {container.name}: {e}") 128 | 129 | # Get environment (mask sensitive data) 130 | try: 131 | env_vars = container.attrs.get("Config", {}).get("Env") or [] 132 | for env_var in env_vars: 133 | if "=" in env_var: 134 | key, value = env_var.split("=", 1) 135 | # Simple masking for common sensitive keys 136 | if any( 137 | word in key.lower() 138 | for word in ["password", "key", "token", "secret"] 139 | ): 140 | value = "***MASKED***" 141 | info["environment"][key] = value 142 | except (KeyError, AttributeError) as e: 143 | logging.warning(f"Error getting environment for {container.name}: {e}") 144 | 145 | containers.append(info) 146 | 147 | return containers 148 | 149 | 150 | def generate_compose(containers): 151 | """Generate basic docker-compose.yml from containers.""" 152 | compose = {"version": "3.8", "services": {}} 153 | 154 | for container in containers: 155 | # Include all containers regardless of status 156 | service_name = container["name"].replace("_", "-") 157 | service = { 158 | "image": container["image"], 159 | "container_name": container["name"], 160 | "restart": "unless-stopped", 161 | } 162 | 163 | if container["ports"]: 164 | service["ports"] = container["ports"] 165 | 166 | if container["volumes"]: 167 | service["volumes"] = container["volumes"] 168 | 169 | # Only add non-masked environment variables 170 | clean_env = { 171 | k: v for k, v in container["environment"].items() if v != "***MASKED***" 172 | } 173 | if clean_env: 174 | service["environment"] = clean_env 175 | 176 | compose["services"][service_name] = service 177 | 178 | return compose 179 | 180 | 181 | def get_system_info(): 182 | """Get basic system information using cached boot data when available.""" 183 | # Get hostname with cached data first, then fallbacks 184 | hostname = "unknown" 185 | try: 186 | # Strategy 1: Use cached hostname from entrypoint (preferred) 187 | cached_hostname = os.environ.get("CACHED_HOSTNAME") 188 | if cached_hostname: 189 | hostname = cached_hostname 190 | logging.info(f"Using cached hostname: {hostname}") 191 | # Strategy 2: Try direct boot config access (fallback) 192 | elif Path("/boot/config/ident.cfg").exists(): 193 | with open("/boot/config/ident.cfg") as f: 194 | for line in f: 195 | if line.startswith("NAME="): 196 | hostname = line.split("=", 1)[1].strip().strip('"') 197 | break 198 | # Strategy 3: Try hostname command 199 | if hostname == "unknown": 200 | result = subprocess.run(["hostname"], capture_output=True, text=True) 201 | hostname = result.stdout.strip() or "unknown" 202 | except Exception: 203 | try: 204 | result = subprocess.run(["hostname"], capture_output=True, text=True) 205 | hostname = result.stdout.strip() or "unknown" 206 | except Exception: 207 | hostname = "unknown" 208 | 209 | info = { 210 | "timestamp": datetime.now().isoformat(), 211 | "hostname": hostname, 212 | "guardian_version": __version__, 213 | } 214 | 215 | # Get Unraid version with cached data first, then fallbacks 216 | unraid_version = "unknown" 217 | try: 218 | # Strategy 1: Use cached version from entrypoint (preferred) 219 | cached_version = os.environ.get("CACHED_UNRAID_VERSION") 220 | if cached_version: 221 | unraid_version = cached_version 222 | logging.info(f"Using cached Unraid version: {unraid_version}") 223 | # Strategy 2: Try direct boot file access (fallback) 224 | elif Path("/boot/changes.txt").exists(): 225 | with open("/boot/changes.txt") as f: 226 | first_line = f.readline().strip() 227 | # Extract version from "# Version 7.1.4 2025-06-18" format 228 | if first_line.startswith("# Version "): 229 | version_part = first_line.replace("# Version ", "").split()[0] 230 | unraid_version = version_part 231 | logging.info(f"Found Unraid version: {unraid_version}") 232 | 233 | # Strategy 3: Try alternative version file locations 234 | if unraid_version == "unknown" and Path("/boot/config/docker.cfg").exists(): 235 | # Sometimes version info is in docker.cfg 236 | with open("/boot/config/docker.cfg") as f: 237 | content = f.read() 238 | if "DOCKER_ENABLED" in content: 239 | logging.info("Detected Unraid system (docker.cfg found)") 240 | unraid_version = ( 241 | "Unraid (version detection from /boot/changes.txt failed)" 242 | ) 243 | 244 | if unraid_version == "unknown": 245 | logging.warning("/boot directory not mounted or accessible") 246 | 247 | except Exception as e: 248 | logging.warning(f"Error reading Unraid version: {e}") 249 | unraid_version = "unknown" 250 | 251 | info["unraid_version"] = unraid_version 252 | 253 | return info 254 | 255 | 256 | def get_container_templates(): 257 | """Get XML templates from Unraid's template directory.""" 258 | templates = [] 259 | 260 | # Refresh cached templates before collection 261 | # Try multiple approaches for running the refresh with elevated privileges 262 | refresh_methods = [ 263 | # Method 1: Try sudo (current approach) 264 | ["sudo", "/usr/local/bin/refresh-templates.sh"], 265 | # Method 2: Try running directly via docker exec (if available) 266 | [ 267 | "docker", 268 | "exec", 269 | "--user", 270 | "root", 271 | os.environ.get("HOSTNAME", "unraid-config-guardian"), 272 | "/usr/local/bin/refresh-templates.sh", 273 | ], 274 | # Method 3: Direct execution (if script is setuid or we have perms) 275 | ["/usr/local/bin/refresh-templates.sh"], 276 | ] 277 | 278 | refresh_script = Path("/usr/local/bin/refresh-templates.sh") 279 | if refresh_script.exists(): 280 | refresh_success = False 281 | for i, method in enumerate(refresh_methods, 1): 282 | try: 283 | logging.info( 284 | f"Attempting template refresh method {i}: {' '.join(method[:2])}..." 285 | ) 286 | result = subprocess.run( 287 | method, 288 | capture_output=True, 289 | text=True, 290 | timeout=30, 291 | ) 292 | if result.returncode == 0: 293 | logging.info( 294 | f"Template cache refreshed successfully using method {i}" 295 | ) 296 | refresh_success = True 297 | break 298 | else: 299 | logging.debug(f"Method {i} failed: {result.stderr}") 300 | except FileNotFoundError: 301 | logging.debug(f"Method {i} tool not available") 302 | continue 303 | except Exception as e: 304 | logging.debug(f"Method {i} error: {e}") 305 | continue 306 | 307 | if not refresh_success: 308 | logging.warning( 309 | "All template refresh methods failed - attempting entrypoint template caching" 310 | ) 311 | # Try to run the template caching portion of entrypoint.sh as root 312 | try: 313 | entrypoint_template_cache_script = """ 314 | #!/bin/bash 315 | # Cache template directory accessibility and copy templates (from entrypoint.sh) 316 | if [ -d "/boot/config/plugins/dockerMan/templates-user" ]; then 317 | echo "Template directory accessible" 318 | 319 | # Create cache directory for templates in /output (persistent location) 320 | mkdir -p /output/cached-templates 321 | 322 | # Copy all XML templates to cache directory (as root, so we can read them) 323 | if [ "$(ls -A /boot/config/plugins/dockerMan/templates-user/*.xml \\ 324 | 2>/dev/null)" ]; then 325 | cp /boot/config/plugins/dockerMan/templates-user/*.xml \\ 326 | /output/cached-templates/ 2>/dev/null || true 327 | template_count=$(ls -1 /output/cached-templates/*.xml 2>/dev/null | wc -l) 328 | echo "Cached $template_count XML templates" 329 | else 330 | echo "No XML templates found in templates-user directory" 331 | fi 332 | else 333 | echo "Template directory not accessible" 334 | fi 335 | """ 336 | 337 | result = subprocess.run( 338 | ["sudo", "bash", "-c", entrypoint_template_cache_script], 339 | capture_output=True, 340 | text=True, 341 | timeout=30, 342 | ) 343 | 344 | if result.returncode == 0: 345 | logging.info( 346 | "Successfully refreshed template cache using entrypoint logic" 347 | ) 348 | # Check if we actually got templates 349 | cached_dir = Path("/output/cached-templates") 350 | if cached_dir.exists() and list(cached_dir.glob("*.xml")): 351 | logging.info("Template cache now contains XML files") 352 | else: 353 | logging.info("Template cache created but no XML files found") 354 | else: 355 | logging.warning( 356 | f"Entrypoint template caching failed: {result.stderr}" 357 | ) 358 | 359 | except Exception as e: 360 | logging.warning(f"Could not run entrypoint template caching: {e}") 361 | 362 | # Use cached templates directory (standard location in /output) 363 | cached_templates_dir = Path("/output/cached-templates") 364 | if cached_templates_dir.exists(): 365 | template_dir = cached_templates_dir 366 | logging.info(f"Using cached templates from: {template_dir}") 367 | else: 368 | # Fallback to direct access (may fail due to permissions) 369 | template_dir = Path("/boot/config/plugins/dockerMan/templates-user") 370 | logging.info("Using direct template directory access") 371 | 372 | if not template_dir.exists(): 373 | logging.info("Template directory not found - no user templates to backup") 374 | return templates 375 | 376 | logging.info(f"Scanning for XML files in: {template_dir}") 377 | 378 | try: 379 | xml_files = list(template_dir.glob("*.xml")) 380 | logging.info(f"Found {len(xml_files)} XML files") 381 | 382 | for xml_file in xml_files: 383 | if xml_file.is_file(): 384 | templates.append( 385 | { 386 | "name": xml_file.name, 387 | "path": str(xml_file), 388 | "size": xml_file.stat().st_size, 389 | } 390 | ) 391 | logging.info(f"Added template: {xml_file.name}") 392 | except Exception as e: 393 | logging.error(f"Error scanning templates directory: {e}") 394 | 395 | logging.info(f"Total templates collected: {len(templates)}") 396 | return templates 397 | 398 | 399 | def create_templates_zip(templates, output_dir): 400 | """Create a zip file containing all XML templates.""" 401 | if not templates: 402 | logging.info("No templates provided for zip creation") 403 | return None 404 | 405 | zip_path = output_dir / "container-templates.zip" 406 | 407 | logging.info(f"Creating template zip at: {zip_path}") 408 | logging.info(f"Templates to zip: {len(templates)}") 409 | 410 | templates_added = 0 411 | 412 | try: 413 | with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: 414 | for template in templates: 415 | template_path = Path(template["path"]) 416 | logging.info( 417 | f"Processing template: {template['name']} at {template_path}" 418 | ) 419 | 420 | if template_path.exists(): 421 | try: 422 | zipf.write(template_path, template["name"]) 423 | templates_added += 1 424 | logging.info(f"Added {template['name']} to zip") 425 | except Exception as template_error: 426 | logging.error( 427 | f"Failed to add {template['name']}: {template_error}" 428 | ) 429 | else: 430 | logging.warning(f"Template file not found: {template_path}") 431 | 432 | if templates_added > 0: 433 | logging.info( 434 | f"Created templates zip: {zip_path} with {templates_added} templates" 435 | ) 436 | 437 | # Clean up cached templates after successful zip creation 438 | # Only remove cached templates if we can access the direct boot config path 439 | try: 440 | cached_dir = Path("/output/cached-templates") 441 | boot_templates_dir = Path( 442 | "/boot/config/plugins/dockerMan/templates-user" 443 | ) 444 | 445 | if cached_dir.exists(): 446 | # Test if we can access the boot config directory before cleaning up cache 447 | try: 448 | if boot_templates_dir.exists() and list( 449 | boot_templates_dir.glob("*.xml") 450 | ): 451 | import shutil 452 | 453 | shutil.rmtree(cached_dir) 454 | logging.info( 455 | "Cleaned up cached templates directory - boot config accessible" 456 | ) 457 | else: 458 | logging.info( 459 | "Keeping cached templates - boot config not accessible" 460 | ) 461 | except (PermissionError, OSError): 462 | logging.info( 463 | "Keeping cached templates - no permission to access boot config" 464 | ) 465 | else: 466 | logging.info("No cached templates directory to clean up") 467 | except Exception as cleanup_error: 468 | logging.warning(f"Could not clean up cached templates: {cleanup_error}") 469 | 470 | return zip_path 471 | else: 472 | logging.error("No templates were successfully added to zip") 473 | # Remove empty zip file 474 | if zip_path.exists(): 475 | zip_path.unlink() 476 | return None 477 | 478 | except Exception as e: 479 | logging.error(f"Error creating templates zip: {e}") 480 | logging.error(f"Exception type: {type(e).__name__}") 481 | return None 482 | 483 | 484 | def create_restore_script(system_info): 485 | """Create restore script for Unraid-native workflow.""" 486 | return f"""#!/bin/bash 487 | # Unraid Config Guardian - Restore Script 488 | # Generated: {system_info['timestamp']} 489 | # Server: {system_info['hostname']} 490 | 491 | echo "🔄 Restoring Unraid setup..." 492 | 493 | # Function to restore XML templates 494 | restore_templates() {{ 495 | if [ -f "container-templates.zip" ]; then 496 | echo "📋 Restoring XML templates..." 497 | 498 | # Create target directory if it doesn't exist 499 | mkdir -p /boot/config/plugins/dockerMan/templates-user 500 | 501 | # Extract templates 502 | unzip -o container-templates.zip -d /boot/config/plugins/dockerMan/templates-user 503 | 504 | if [ $? -eq 0 ]; then 505 | echo "✅ XML templates restored to /boot/config/plugins/dockerMan/templates-user" 506 | echo "ℹ️ Templates will appear in 'Add Container' dropdown" 507 | else 508 | echo "❌ Failed to extract templates" 509 | fi 510 | else 511 | echo "ℹ️ No container-templates.zip found - skipping template restore" 512 | fi 513 | }} 514 | 515 | # Function to attempt docker-compose restore (fallback option) 516 | restore_with_compose() {{ 517 | if [ -f "docker-compose.yml" ]; then 518 | echo "" 519 | echo "🐳 Attempting docker-compose restore (fallback method)..." 520 | 521 | if command -v docker-compose &> /dev/null; then 522 | docker-compose up -d 523 | echo "✅ Containers started with docker-compose" 524 | elif docker compose version &> /dev/null; then 525 | docker compose up -d 526 | echo "✅ Containers started with docker compose" 527 | else 528 | echo "❌ Docker Compose not available" 529 | echo "💡 Install with: curl -L 'https://github.com/docker/compose/releases/" \ 530 | "latest/download/docker-compose-$(uname -s)-$(uname -m)' " \ 531 | "-o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose" 532 | return 1 533 | fi 534 | fi 535 | }} 536 | 537 | # Main restore process 538 | echo "📋 UNRAID RESTORE OPTIONS:" 539 | echo "" 540 | echo "Option 1: Restore XML Templates (Recommended)" 541 | restore_templates 542 | 543 | echo "" 544 | echo "Option 2: Docker-Compose Fallback (Emergency only)" 545 | if restore_with_compose; then 546 | echo "⚠️ Warning: These containers bypass Unraid's management system" 547 | fi 548 | 549 | echo "" 550 | echo "✅ Restore process complete!" 551 | echo "" 552 | echo "📋 NEXT STEPS:" 553 | echo " 1. Go to Docker tab in Unraid WebUI" 554 | echo " 2. Click 'Add Container'" 555 | echo " 3. Select your templates from 'Template' dropdown" 556 | echo " 4. Configure paths/settings as needed" 557 | echo " 5. Restore appdata from backup" 558 | echo "" 559 | echo "💡 TIPS:" 560 | echo " - Enable 'Template Authoring Mode' in Docker settings for full template access" 561 | echo " - Use unraid-config.json for reference settings" 562 | echo " - Templates provide better integration than docker-compose" 563 | echo " - Templates support Unraid's auto-start, updates, and management features" 564 | """ 565 | 566 | 567 | def create_readme(system_info, container_count): 568 | """Create simple README.""" 569 | return f"""# Unraid Backup Documentation 570 | 571 | **Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} 572 | **Server:** {system_info['hostname']} 573 | **Containers:** {container_count} 574 | 575 | ## Quick Recovery (Recommended: Unraid Templates) 576 | 577 | 1. Install fresh Unraid 578 | 2. Restore flash drive from backup 579 | 3. Set up disk array 580 | 4. Run: `bash restore.sh` (restores XML templates) 581 | 5. Go to Docker tab → Add Container → Select your templates 582 | 6. Configure paths and restore appdata from backup 583 | 584 | ## Files 585 | 586 | - `unraid-config.json` - Complete system configuration 587 | - `container-templates.zip` - XML templates for native Unraid restore 588 | - `docker-compose.yml` - Fallback container definitions 589 | - `restore.sh` - Automated restoration script 590 | - `README.md` - This file 591 | 592 | ## Restore Methods 593 | 594 | ### Method 1: Native Unraid Templates (Recommended) 595 | ```bash 596 | bash restore.sh # Extracts templates to /boot/config/plugins/dockerMan/templates-user 597 | ``` 598 | Then use Unraid WebUI to add containers from templates. 599 | 600 | ### Method 2: Docker Compose (Emergency Fallback) 601 | ```bash 602 | docker-compose up -d # Or: docker compose up -d 603 | docker-compose ps 604 | ``` 605 | Note: Bypasses Unraid's container management system. 606 | 607 | Keep this documentation safe and test your restore process! 608 | """ 609 | 610 | 611 | def setup_logging(debug: bool = False, output_dir: str = "/output") -> None: 612 | """Setup logging configuration.""" 613 | level = logging.DEBUG if debug else logging.INFO 614 | 615 | # Ensure output directory exists 616 | log_dir = Path(output_dir) 617 | log_dir.mkdir(parents=True, exist_ok=True, mode=0o755) 618 | # Set permissions explicitly for Unraid compatibility 619 | os.chmod(log_dir, 0o755) 620 | 621 | # Setup handlers 622 | handlers: List[logging.Handler] = [logging.StreamHandler()] 623 | 624 | # Add file handler if we can write to the directory 625 | log_file = log_dir / "guardian.log" 626 | try: 627 | handlers.append(logging.FileHandler(log_file, mode="a")) 628 | except (OSError, PermissionError): 629 | # If we can't write to the specified location, just use console logging 630 | pass 631 | 632 | logging.basicConfig( 633 | level=level, 634 | format="%(asctime)s - %(levelname)s - %(message)s", 635 | handlers=handlers, 636 | ) 637 | 638 | 639 | def main(): 640 | """Main function.""" 641 | parser = argparse.ArgumentParser(description="Unraid Config Guardian") 642 | parser.add_argument( 643 | "--output", default=os.getenv("OUTPUT_DIR", "/output"), help="Output directory" 644 | ) 645 | parser.add_argument("--debug", action="store_true", help="Enable debug logging") 646 | args = parser.parse_args() 647 | 648 | setup_logging(args.debug, args.output) 649 | logger = logging.getLogger(__name__) 650 | 651 | output_dir = Path(args.output) 652 | output_dir.mkdir(parents=True, exist_ok=True, mode=0o755) 653 | # Set permissions explicitly for Unraid compatibility 654 | os.chmod(output_dir, 0o755) 655 | 656 | logger.info("🚀 Generating Unraid documentation...") 657 | 658 | try: 659 | # Collect data 660 | logger.info("📦 Collecting container information...") 661 | containers = get_containers() 662 | 663 | logger.info("🖥️ Collecting system information...") 664 | system_info = get_system_info() 665 | 666 | logger.info("📋 Collecting XML templates...") 667 | templates = get_container_templates() 668 | 669 | logger.info("📝 Generating docker-compose configuration...") 670 | compose = generate_compose(containers) 671 | 672 | # Create complete config 673 | config = {"system_info": system_info, "containers": containers} 674 | 675 | # Generate change log (compare with previous backup) 676 | change_log = create_change_log(output_dir, config) 677 | 678 | # Write files 679 | files = { 680 | "unraid-config.json": json.dumps(config, indent=2), 681 | "docker-compose.yml": ( 682 | f"# Generated by Config Guardian\n# {system_info['timestamp']}\n\n" 683 | + yaml.dump(compose, default_flow_style=False) 684 | ), 685 | "restore.sh": create_restore_script(system_info), 686 | "README.md": create_readme(system_info, len(containers)), 687 | } 688 | 689 | for filename, content in files.items(): 690 | file_path = output_dir / filename 691 | file_path.write_text(content) 692 | logger.info(f"✅ Created {filename}") 693 | 694 | # Make restore script executable 695 | os.chmod(output_dir / "restore.sh", 0o755) 696 | 697 | # Create templates zip if templates exist 698 | if templates: 699 | logger.info("📦 Creating container templates zip...") 700 | create_templates_zip(templates, output_dir) 701 | logger.info(f"✅ Found {len(templates)} XML templates") 702 | else: 703 | logger.info("ℹ️ No XML templates found to backup") 704 | 705 | logger.info(f"🎉 Documentation generated in {output_dir}") 706 | logger.info(f"📦 Found {len(containers)} containers") 707 | 708 | # Log change summary 709 | if change_log: 710 | logger.info("📋 Change log generated - see changes.log for details") 711 | else: 712 | logger.info("📋 First backup - no changes to compare") 713 | 714 | except Exception as e: 715 | logger.error(f"❌ Error generating documentation: {str(e)}") 716 | raise 717 | 718 | 719 | if __name__ == "__main__": 720 | main() 721 | --------------------------------------------------------------------------------