Files
ai-suite/suite_services.py
T

1130 lines
48 KiB
Python

#!/usr/bin/env python3
"""
Trevor SANDY
Last Update January 09, 2026
Copyright (c) 2025-Present by Trevor SANDY
AI-Suite uses this script for the installation command that handles the AI-Suite
functional module selection, Ollama CPU/GPU configuration, and starting Supabase and
Open WebUI Filesystem when specified.
If specified, the Supabase stack is started first. The script waits for it to initialize,
then starts open-webui filesystem tool - if specified, and then starts the AI-Suite stack.
All stacks use the same Docker Compose services project name ("ai-suite") so they appear
grouped together in Docker Desktop.
This script is also used for operation commands that start, stop, stop-ollama,
pause, unpause, update and install the AI-Suite services using the optional
--operation argument. An Ollama check is performed when it is assumed Ollama is
running from the Docker Host. If Ollama is determined to be installed but not running,
an attempt to launch the Ollama service is executed on install, start and unpause.
The check will also attempt to stop the Ollama service (in addition to stopping the
AI-Suite services) when the stop-ollama operational command is specified.
Both installation and operation commands utilize the optional --profile
arguments to specify which AI-Suite functional modules and which Ollama CPU/GPU
configuration to use. When no functional profile argument is specified, the
default functional module open-webui is used, Likewise, if no CPU/GPU configuration
profile is specified, it is assumed Ollama is being run from the Docker Host.
Multiple profile arguments (functional modules) are supported.
The --environment command allows the installation to be defined as private (default)
or public. A public install restricts the communication ports exposed to the network.
For full installation and operation details, see the AI-Suite repository README.md
This script has been adapted from start_services.py by Cole Medin
This program is free software; you can redistribute it and/or modify it under
the terms of the Apache License, Version 2.0.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import sys
import datetime
import subprocess
import pathlib
import shutil
import time
import argparse
import platform
import dotenv
import tempfile
import textwrap
import re
info = {
"name": "AI-Suite",
"version": (0, 4, 0),
"title": "AI-Suite installation and operation",
"file": os.path.basename(__file__),
"description": "A dockerized suite of AI agents in a no-code, workflow, LLM environment",
"author": "Trevor SANDY <trevor.sandy@gmail.com>",
"author_url": "https://github.com/trevorsandy/",
"repository": "https://github.com/trevorsandy/ai-suite",
"issues": "https://github.com/trevorsandy/ai-suite/issues",
"license": "Apache License 2.0",
"copyright": "Copyright (c) 2025-present by Trevor SANDY"
}
def run_command(cmd, cwd=None):
"""Run a shell command and print it."""
print("Running command:", " ".join(cmd))
try:
completed = subprocess.run(cmd, cwd=cwd, check=True)
if completed.returncode != 0:
print(f"Command Error: {completed.stderr}")
except Exception as e:
print(f"Command Exception: {e}.")
def launch_ollama_process():
"""Launch Ollama inference server on host"""
cmd = " ".join([ollama_exe, "serve"])
print("Running command:", cmd)
if system == "Windows":
path = tempfile.gettempdir()
ollama_launch = os.path.join(path, "ollama_launch.vbs")
print("Command script:", ollama_launch)
with open(ollama_launch, 'w') as f:
f.write(textwrap.dedent(f"""\
' Generated from {info.get("file")} on: {datetime.datetime.now().ctime()}
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "{cmd}", 0, False
Set WshShell = Nothing
"""))
os.startfile(ollama_launch)
else: # Unix-based systems (Linux, macOS)
os.system(cmd)
global attempted_launch
attempted_launch = True
print("Waiting for Ollama on host to initialize...")
time.sleep(4)
check_ollama_process(None)
def check_ollama_process(operation=None):
"""Check for Ollama (on host) and attempt to launch if not running."""
if not attempted_launch:
print("Checking for Ollama process on host...")
ollama_running = False
ollama_proc = ollama_app.lower()
try:
if system == "Windows":
cmd = ["tasklist"]
else: # Unix-based systems (Linux, macOS)
cmd = ["pgrep", "-f", ollama_proc]
print("Running command:", " ".join(cmd))
check_proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
if system == "Windows":
ollama_running = True if ollama_proc in check_proc.stdout.lower() else False
else: # Unix-based systems (Linux, macOS)
ollama_running = check_proc.returncode == 0 if check_proc else False
except Exception as e:
print(f"Exception: Ollama process: {e} - assuming Ollama is not running.")
stop_ollama = operation == 'stop-ollama'
if ollama_running:
if stop_ollama:
print("Stopping Ollama process on host...")
if system == "Windows":
cmd = ["taskkill", "/f", "/im", ollama_proc]
else: # Unix-based systems (Linux, macOS)
cmd = ["ps", "-C", ollama_proc, "-o", "pid=|xargs", "kill", "-9"]
print("Running command:", " ".join(cmd))
os.system(" ".join(cmd))
else:
insert = "is now" if attempted_launch else "is"
print(f"Ollama on host {insert} running...")
else:
if attempted_launch:
print("Failed to launch Ollama on host - exiting...")
sys.exit(1)
print("Ollama is not running...")
if not stop_ollama:
if ollama_found:
print("Attempting to launch Ollama on host...")
launch_ollama_process()
else:
print(textwrap.dedent(f"""\
Error: The {ollama_app} file was not found at {ollama_exe}.
If Ollama is installed in a non-standard location, you can set the OLLAMA_PATH
environment variable with its full path (including the Ollama file) to .env and
re-run this file - exiting...
"""))
sys.exit(1)
def clone_supabase_repo():
"""Clone the Supabase repository using sparse checkout if not already present."""
if not os.path.exists("supabase"):
print("Cloning the Supabase repository...")
run_command([
"git", "clone", "--filter=blob:none", "--no-checkout",
"https://github.com/supabase/supabase.git"
])
os.chdir("supabase")
run_command(["git", "sparse-checkout", "init", "--cone"])
run_command(["git", "sparse-checkout", "set", "docker"])
run_command(["git", "checkout", "master"])
os.chdir("..")
else:
print("Supabase repository already exists, updating...")
os.chdir("supabase")
run_command(["git", "pull"])
os.chdir("..")
def clone_open_webui_tools_filesystem_repo():
"""Clone the Open WebUI Tools Filesystem repository using sparse checkout if
not already present.
"""
repo_path = os.path.join("open-webui", "tools", "servers")
if not os.path.exists(repo_path):
os.chdir("open-webui")
print("Cloning the Open WebUI Tools Filesystem repository...")
run_command([
"git", "clone", "--filter=blob:none", "--no-checkout",
"https://github.com/open-webui/openapi-servers.git", "tools"
])
os.chdir("tools")
run_command(["git", "sparse-checkout", "init", "--cone"])
run_command(["git", "sparse-checkout", "set", "servers/filesystem"])
run_command(["git", "checkout", "main"])
os.chdir("../../")
else:
repo_path = os.path.join("open-webui", "tools")
print("Open WebUI Tools Filesystem repository already exists, updating...")
os.chdir(repo_path)
run_command(["git", "pull"])
os.chdir("../../")
def clone_open_webui_functions_repos():
"""Clone the Open WebUI Functions repository using sparse checkout if not
already present.
"""
repo_path = os.path.join("open-webui", "functions", "open-webui")
if not os.path.exists(repo_path):
os.mkdir("open-webui/functions")
os.chdir("open-webui/functions")
print("Cloning the Open WebUI Functions repository...")
run_command([
"git", "clone", "--filter=blob:none", "--no-checkout",
"https://github.com/open-webui/functions.git", "open-webui"
])
os.chdir("open-webui")
run_command(["git", "sparse-checkout", "init", "--cone"])
run_command(["git", "sparse-checkout", "set", "functions/filters", "functions/pipes/openai"])
run_command(["git", "checkout", "main"])
os.chdir("../../../")
else:
print("Open WebUI Functions repository already exists, updating...")
os.chdir(repo_path)
run_command(["git", "pull"])
os.chdir("../../../")
repo_path = os.path.join("open-webui", "functions", "owndev")
if not os.path.exists(repo_path):
os.chdir("open-webui/functions")
print("Cloning the Open WebUI Owndev Functions repository...")
run_command([
"git", "clone", "--filter=blob:none", "--no-checkout",
"https://github.com/owndev/Open-WebUI-Functions.git", "owndev"
])
os.chdir("owndev")
run_command(["git", "sparse-checkout", "init", "--cone"])
run_command(["git", "sparse-checkout", "set", "pipelines/n8n", "filters", "docs"])
run_command(["git", "checkout", "main"])
os.chdir("../../../")
else:
print("Open WebUI Functions repository already exists, updating...")
os.chdir(repo_path)
run_command(["git", "pull"])
os.chdir("../../../")
os.chdir(repo_path)
docs_dir = os.path.join("docs")
retain = ["n8n-integration.md", "n8n-tool-usage-display.md"]
os.chdir(docs_dir)
for item in os.listdir(os.getcwd()):
if item not in retain:
if pathlib.Path(item).is_file():
os.remove(item)
elif pathlib.Path(item).is_dir():
shutil.rmtree(item)
os.chdir("../../../../")
def prepare_supabase_env(env_vars):
"""Write env_vars to .env in supabase/docker."""
env_file = os.path.join("supabase", "docker", ".env")
built_env_vars = env_vars
built_env_vars['COMPOSE_IGNORE_ORPHANS'] = 'true'
write_dotenv_file(env_file, built_env_vars)
def prepare_open_webui_tools_filesystem_env(env_vars):
"""Write env_vars to .env and compose.yaml to open-webui/tools/servers/filesystem."""
env_file = os.path.join("open-webui", "tools", "servers", "filesystem", ".env")
built_env_vars = env_vars
built_env_vars['COMPOSE_IGNORE_ORPHANS'] = 'true'
write_dotenv_file(env_file, built_env_vars)
docker_compose_path = os.path.join("open-webui", "tools", "servers", "filesystem", "compose.yaml")
print(f"Writing {docker_compose_path}...")
with open(docker_compose_path, 'w') as f:
f.write(textwrap.dedent("""\
services:
open-webui-filesystem:
container_name: open-webui-filesystem
restart: unless-stopped
build:
context: .
ports:
- 8091:8091
extra_hosts:
- host.docker.internal:host-gateway
volumes:
- ${PROJECTS_PATH:-../shared}:/nonexistent/tmp
environment:
- PROJECTS_PATH
"""))
def destroy_ai_suite(profile, install):
"""Stop and remove AI-Suite containers and volumes (using its compose file)
for the specified profile arguments.
"""
insert = "and volumes for" if install else "for"
print(f"Destroying {name} containers {insert} profile arguments: {profile}...")
cmd = ["docker", "compose", "-p", "ai-suite"]
if profile:
for argument in profile:
cmd.extend(["--profile", argument])
cmd.extend(["-f", "docker-compose.yml", "down"])
if install:
cmd.extend(["--volumes"])
run_command(cmd)
if install:
cmd = ["docker", "volume", "prune", "--force"]
run_command(cmd)
def operate_ai_suite(operation, profile, environment, env_vars):
"""Start, stop, pause or pull the AI-Suite containers (using its compose file)
for the specified profile arguments and environment argument.
"""
if not operation:
operation = "stop"
with open('.operation', 'w') as f:
f.write(operation)
supabase = False
open_webui = False
if profile and operation != 'pull':
supabase = any(p for p in profile if p in ['supabase', 'ai-all'])
open_webui = any(p for p in profile if p in open_webui_all_profiles)
if operation == 'start' and environment:
env_vars['POSTGRES_HOST'] = "db" if supabase else "postgres"
load_dotenv_vars(env_vars)
if supabase:
start_supabase(environment, False)
print("""Waiting for Supabase to initialize...""")
time.sleep(10)
if open_webui:
start_open_webui_tools_filesystem(environment, False)
time.sleep(1)
start_ai_suite(profile, environment, False)
return
if operation == 'stop':
insert = "Stopping"
elif operation == 'pull':
insert = "Pulling"
else:
insert = "Pausing" if operation == 'pause' else None
container = "images" if operation == 'pull' else "containers"
print(f"{insert} '{name}' {container} for profile arguments: {profile}...")
base = ["docker", "compose", "-p", "ai-suite"]
if supabase:
cmd = base + ["-f", "supabase/docker/docker-compose.yml", operation]
run_command(cmd)
if open_webui:
cmd = base + ["-f", "open-webui/tools/servers/filesystem/compose.yaml", operation]
run_command(cmd)
cmd = base
if profile:
for argument in profile:
cmd.extend(["--profile", argument])
cmd.extend(["-f", "docker-compose.yml", operation])
run_command(cmd)
if operation == 'pull':
cmd = ["docker", "image", "prune", "--force"]
run_command(cmd)
def start_built_container(compose_file=None, environment=None, build=False):
"""Start the locally built container services (using its compose file)."""
cmd = ["docker", "compose", "-p", "ai-suite", "-f", compose_file]
if environment == "public":
cmd.extend(["-f", "docker-compose.override.public.yml"])
cmd.extend(["up", "-d"])
if build:
cmd.extend(["--build"])
run_command(cmd)
def start_supabase(environment=None, build=False):
"""Start the Supabase services (using its compose file)."""
print("Starting Supabase services...")
compose_file = "supabase/docker/docker-compose.yml"
start_built_container(compose_file, environment, build)
def start_open_webui_tools_filesystem(environment=None, build=False):
"""Start the Open WebUI Tools Filesystem services (using its compose file)."""
print("Starting Open WebUI Tools Filesystem services...")
compose_file = "open-webui/tools/servers/filesystem/compose.yaml"
start_built_container(compose_file, environment, build)
def start_ai_suite(profile=None, environment=None, build=False):
"""Start the AI-Suite services (using its compose file) for the specified
profile arguments and environment argument.
"""
print(f"Starting {name} services for profile arguments: {profile}...")
cmd = ["docker", "compose", "-p", "ai-suite"]
if profile:
for argument in profile:
cmd.extend(["--profile", argument])
else:
cmd.extend(["--profile", 'open-webui'])
cmd.extend(["-f", "docker-compose.yml"])
if environment == "private":
cmd.extend(["-f", "docker-compose.override.private.yml"])
if environment == "public":
cmd.extend(["-f", "docker-compose.override.public.yml"])
cmd.extend(["up", "-d"])
if build:
cmd.extend(["--remove-orphans"])
run_command(cmd)
def generate_searxng_secret_key():
"""Generate a secret key for SearXNG based on the current platform."""
print("Checking SearXNG settings...")
# Define paths for SearXNG settings files
settings_path = os.path.join("searxng", "settings.yml")
settings_base_path = os.path.join("searxng", "settings-base.yml")
# Check if settings-base.yml exists
if not os.path.exists(settings_base_path):
print(f"Warning: SearXNG base settings file not found at {settings_base_path}")
return
# Check if settings.yml exists, if not create it from settings-base.yml
if not os.path.exists(settings_path):
print(f"SearXNG settings.yml not found. Creating from {settings_base_path}...")
try:
shutil.copyfile(settings_base_path, settings_path)
print(f"Created {settings_path} from {settings_base_path}")
except Exception as e:
print(f"Exception: Create SearXNG settings.yml: {e}")
return
else:
print(f"SearXNG settings.yml already exists at {settings_path}")
print("Generating SearXNG secret key...")
# Run the appropriate platform command
try:
if system == "Windows":
print("Using Windows PowerShell to generate secret key...")
# PowerShell command to generate a random key and replace in the settings file
ps_command = [
"powershell", "-Command",
"$randomBytes = New-Object byte[] 32; " +
"(New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($randomBytes); " +
"$secretKey = -join ($randomBytes | ForEach-Object { \"{0:x2}\" -f $_ }); " +
"(Get-Content searxng/settings.yml) -replace 'ultrasecretkey', $secretKey | Set-Content searxng/settings.yml"
]
subprocess.run(ps_command, check=True)
elif system == "Darwin": # macOS
print("Using macOS sed command with empty string parameter...")
# macOS sed command requires an empty string for the -i parameter
openssl_cmd = ["openssl", "rand", "-hex", "32"]
random_key = subprocess.check_output(openssl_cmd).decode('utf-8').strip()
sed_cmd = ["sed", "-i", "", f"s|ultrasecretkey|{random_key}|g", settings_path]
subprocess.run(sed_cmd, check=True)
else: # Linux and other Unix-like systems
print("Using standard Linux/Unix sed command...")
# Standard sed command for Linux
openssl_cmd = ["openssl", "rand", "-hex", "32"]
random_key = subprocess.check_output(openssl_cmd).decode('utf-8').strip()
sed_cmd = ["sed", "-i", f"s|ultrasecretkey|{random_key}|g", settings_path]
subprocess.run(sed_cmd, check=True)
print("SearXNG secret key generated successfully.")
except Exception as e:
print(f"Exception: Generate SearXNG secret key: {e}")
print("You may need to manually generate the secret key using the commands:")
print(" - Linux: sed -i \"s|ultrasecretkey|$(openssl rand -hex 32)|g\" searxng/settings.yml")
print(" - macOS: sed -i '' \"s|ultrasecretkey|$(openssl rand -hex 32)|g\" searxng/settings.yml")
print(" - Windows (PowerShell):")
print(" $randomBytes = New-Object byte[] 32")
print(" (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($randomBytes)")
print(" $secretKey = -join ($randomBytes | ForEach-Object { \"{0:x2}\" -f $_ })")
print(" (Get-Content searxng/settings.yml) -replace 'ultrasecretkey', $secretKey | Set-Content searxng/settings.yml")
def check_and_fix_docker_compose_for_searxng():
"""Check and modify docker-compose.yml for SearXNG first run."""
docker_compose_path = "docker-compose.yml"
if not os.path.exists(docker_compose_path):
print(f"Warning: Docker Compose file not found at {docker_compose_path}")
return
try:
# Default to first run
is_first_run = True
# Check if Docker is running and if the SearXNG container exists
try:
# Check if the SearXNG container is running
container_check = subprocess.run(
["docker", "ps", "--filter", "name=searxng", "--format", "{{.Names}}"],
capture_output=True, text=True, check=True
)
searxng_containers = container_check.stdout.strip().split('\n')
# If SearXNG container is running, check inside for uwsgi.ini
if any(container for container in searxng_containers if container):
container_name = next(container for container in searxng_containers if container)
print(f"Found running SearXNG container: {container_name}")
# Check if uwsgi.ini exists inside the container
container_check = subprocess.run(
["docker", "exec", container_name, "sh", "-c", "[ -f /etc/searxng/uwsgi.ini ] && echo 'found' || echo 'not_found'"],
capture_output=True, text=True, check=False
)
if "found" in container_check.stdout:
print("Found uwsgi.ini inside the SearXNG container - not first run")
is_first_run = False
else:
print("uwsgi.ini not found inside the SearXNG container - first run")
is_first_run = True
else:
print("No running SearXNG container found - assuming first run")
is_first_run = True
except Exception as e:
print(f"Exception: Check Docker container running: {e} - assuming first run")
# Temporarily comment out the cap_drop line on first run
if is_first_run:
print("First run detected for SearXNG. Temporarily commenting 'cap_drop:' directive...")
with open(docker_compose_path, 'r+') as f:
commented = False
searxng_found = False
lines = f.readlines()
f.seek(0)
f.truncate()
for line in lines:
if not commented:
compare = line.strip()
if compare == 'searxng:':
searxng_found = True
if searxng_found:
if compare == 'cap_drop:':
line = " #cap_drop:\n"
if compare == '- ALL':
line = " # - ALL # Temporarily commented out for first run\n"
commented = True
searxng_found = False
print("SearXNG 'cap_drop:' directive temporarily commented...")
f.write(line)
print("Note: After the first run completes successfully, uncomment 'cap_drop:' in docker-compose.yml for security.")
else:
# Read the docker-compose.yml file
with open(docker_compose_path, 'r') as f:
content = f.read()
# Uncomment the cap_drop line
cap_drop_comment = " #cap_drop:\n # - ALL # Temporarily commented out for first run\n"
if cap_drop_comment in content:
print(f"SearXNG has been initialized. Uncommenting 'cap_drop:' directive for security...")
cap_drop = "cap_drop:\n - ALL\n"
modified_content = content.replace(cap_drop_comment, cap_drop)
# Write the modified content back
with open(docker_compose_path, 'w') as f:
f.write(modified_content)
except Exception as e:
print(f"Exception: Check/modify docker-compose.yml for SearXNG: {e}")
# Treat Selfhosted Supavisor Pooler Keeps Restarting.
# See: https://github.com/supabase/supabase/issues/30210
def convert_supabase_pooler_line_endings():
"""converting Windows line endings to Linux/Unix/MacOS line endings."""
if system == "Windows":
print("Converting supavisor pooler line endings...")
WINDOWS_LINE_ENDING = b'\r\n'
UNIX_LINE_ENDING = b'\n'
file_path = r"supabase/docker/volumes/pooler/pooler.exs"
with open(file_path, 'rb') as f:
content = f.read()
# Windows ➡ Unix
content = content.replace(WINDOWS_LINE_ENDING, UNIX_LINE_ENDING)
with open(file_path, 'wb') as f:
f.write(content)
def docker_compose_include(supabase=False, filesystem=False):
"""Add or remove Supabase and Filesystem include compose.yml in docker-compose.yml"""
compose_file = "docker-compose.yml"
supabase_compose_file = "supabase/docker/docker-compose.yml"
filesystem_compose_file = "open-webui/tools/servers/filesystem/compose.yaml"
if not os.path.exists(compose_file):
print(f"Error: Docker Compose file '{compose_file}' not found - include skipped...")
return
if supabase and not os.path.exists(supabase_compose_file):
print(f"Warning: Include file '{supabase_compose_file}' not found.")
supabase = False
if filesystem and not os.path.exists(filesystem_compose_file):
print(f"Warning: Include file '{filesystem_compose_file}' not found.")
filesystem = False
supabase_ins = "add" if supabase else "remove"
filesystem_ins = "add" if filesystem else "remove"
print(f"Perform {supabase_ins} Supabase and {filesystem_ins} Filesystem "
f"'include:' in {compose_file}...")
include = supabase or filesystem
compose_include = "include:\n"
supabase_include = f" - ./{supabase_compose_file}\n"
filesystem_include = f" - ./{filesystem_compose_file}\n"
try:
with open(compose_file, 'r') as f:
content = f.read()
if include:
if compose_include in content:
content = content.replace(compose_include, "")
else:
print(f"Adding 'include:' element to {compose_file}...")
content = "\n" + content
elif compose_include in content:
print(f"Removing 'include:' element from {compose_file}...")
if supabase and supabase_include not in content:
print(f"Adding include file ./{supabase_compose_file}...")
content = supabase_include + content
elif not supabase and supabase_include in content:
print(f"Removing include file ./{supabase_compose_file}...")
content = content.replace(supabase_include, "")
if filesystem and filesystem_include not in content:
print(f"Adding include file ./{filesystem_compose_file}...")
content = filesystem_include + content
elif not filesystem and filesystem_include in content:
print(f"Removing include file ./{filesystem_compose_file}...")
content = content.replace(filesystem_include, "")
if include and not compose_include in content:
content = compose_include + content
elif not include and compose_include in content:
content = content.replace(compose_include + "\n", "")
with open(compose_file, 'w') as f:
f.write(content)
except Exception as e:
print(f"Exception: Set 'include:' in {compose_file}: {e}")
def normalize_path(path):
"""Normalize path for current platform"""
if path:
if path.startswith('~'):
path = os.path.expanduser(path)
# Convert to absolute path
path = os.path.abspath(path)
return path
def check_prerequisites():
"""Check if required tools are installed"""
required_tools = ['docker', 'git']
missing_tools = []
for tool in required_tools:
if shutil.which(tool) is None:
missing_tools.append(tool)
if missing_tools:
print(f"Missing required tools: {', '.join(missing_tools)}")
print("Install them before continuing.")
return False
try:
subprocess.run(["docker", "info"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
print("Docker is not running. Start Docker Desktop.")
return False
return True
def get_dotenv_vars(env_file=None, force=False):
"""Load environment variables from .env file"""
if env_file is None:
env_file = os.path.join(".env")
if not os.path.exists(env_file):
if os.path.exists(".env.example"):
print("The .env file was not found - creating from .env.example template...")
shutil.copy('.env.example', '.env')
print("⚠️ IMPORTANT: Edit .env file with secure passwords and keys - exiting...")
else:
print("The .env file was not found - exiting...")
return {}
else:
with open(env_file, 'r') as f:
env_content = f.read()
default_secrets = [
'N8N_ENCRYPTION_KEY=change_me_to_a_long_super-secret-key',
'N8N_RUNNERS_AUTH_TOKEN=change_me_to_a_long_super-secret-key',
'N8N_USER_MANAGEMENT_JWT_SECRET=change_me_to_a_longer_even-more-secret',
'POSTGRES_PASSWORD=your-super-secret-postgres-password',
'JWT_SECRET=your-super-secret-jwt-token-with-at-least-40-characters-long',
'DASHBOARD_PASSWORD=your-super-secret-password-1',
'FLOWISE_PASSWORD=your-super-secret-postgres-password',
'NEO4J_AUTH=neo4j/your-super-secret-password-2',
'CLICKHOUSE_PASSWORD=your-super-secret-password-3',
'MINIO_ROOT_PASSWORD=your-super-secret-password-4',
'LANGFUSE_SALT=your-super-secret-key-1',
'NEXTAUTH_SECRET=your-super-secret-key-2',
'ENCRYPTION_KEY=your-super-secret-key-3']
unset_secrets = []
for secret in default_secrets:
if secret in env_content:
unset_secrets.append(secret)
if unset_secrets and not force:
print("ERROR: YOUR .env FILE CONTAINS DEFAULT VALUES THAT NEED TO BE CHANGED!")
for secret in unset_secrets:
print(f"⚠️ CHANGE THIS VALUE ⚠️: {secret}")
print("Exiting...")
return {}
env_vars = dotenv.dotenv_values(env_file)
if not env_vars['PROJECTS_PATH']:
env_vars['PROJECTS_PATH'] = normalize_path(os.path.join('~', 'projects'))
return env_vars
def load_dotenv_vars(env_vars):
"""Load working environment variables into environment"""
if env_vars:
print("Loading working environment variables...")
for env, var in env_vars.items():
if not var:
continue
os.environ[env] = var
else:
env_file = os.path.join(".env")
if not os.path.exists(env_file):
if not get_dotenv_vars():
sys.exit(1)
print("Loading .env environment variables...")
dotenv.load_dotenv(env_file)
def write_dotenv_file(env_file, env_vars):
"""Copy .env to .env in target compose file destination."""
if env_file is None:
print("Error: The .env file path to be written is not defined!")
return
if env_vars is None:
print("Error: The env_vars dictionary to be written is empty!")
return
print(f"Writing .env file to {env_file}...")
with open(env_file, 'w') as f:
now = " ".join(['on:', datetime.datetime.now().ctime()])
f.seek(0)
f.truncate()
f.write(textwrap.dedent(f"""\
# Generated {now} from {info.get("file")} working .env environment variables.
"""))
f.write('\n')
for env, var in env_vars.items():
if not var:
continue
quoted_var = var if var.isalnum() else f"'{var}'"
f.write("".join([env, '=', quoted_var, '\n']))
def set_dotenv_key(env_file, key, value, header):
"""Set or unset an environment variable and add optional header in .env file"""
if not key:
print("Error: A valid .env key was not specified.")
return
if env_file is None:
env_file = os.path.join(".env")
if not value:
try:
with open(env_file, 'r+') as f:
lines = f.readlines()
f.seek(0)
f.truncate()
for line in lines:
line_array = line.strip().split('=')
if len(line_array) > 1 and key == line_array[0].strip():
value_array = line_array[1].strip().split('#')
mod_line = line
if len(value_array) > 1 and value == value_array[0].strip():
mod_line = ''.join([key, ' #', value_array[1], '\n'])
elif len(value_array) == 1:
mod_line = ''.join([key, '\n'])
line = mod_line
f.write("# Omitted '=' to return 'None' vs empty 'str' when not set")
print(f"Notice: Value for {key} removed...")
f.write(line)
except FileNotFoundError:
print(f"Exception: File '{env_file}' not found.")
return
quote_mode = "auto"
if header is not None:
with open(env_file, 'r') as f:
content = f.read()
if not header in content:
quote_mode = "never"
key = "".join([header, key])
print(f"Set '{key}' to {quote_mode}-quote '{value}' in {env_file}...")
dotenv.set_key(env_file, key, value, quote_mode)
def configure_n8n_database_settings(supabase):
"""Set n8n database depends_on and postgres volume in Docker Compose file."""
compose_file = os.path.join("docker-compose.yml")
try:
with open(compose_file, 'r') as f:
content = f.read()
old_vol = "postgres_data" if supabase else "langfuse_postgres_data"
regex = r"\b{}\b\:".format(old_vol)
if re.search(regex, content):
new_vol = "langfuse_postgres_data:" if supabase else "postgres_data:"
print(f"Set Postgres volume: from '{old_vol}' to '{new_vol}' in {compose_file}...")
modified_content = re.sub(r"\b{}\b\:".format(old_vol), new_vol, content)
with open(compose_file, 'w') as f:
f.write(modified_content)
with open(compose_file, 'r+') as f:
n8n_updated = False
n8n_update = False
old_db = "postgres:" if supabase else "db:"
new_db = "db:" if supabase else "postgres:"
lines = f.readlines()
f.seek(0)
f.truncate()
for line in lines:
if not n8n_updated:
if line == ' n8n-import:\n':
n8n_update = True
if line == ' n8n-runner:\n':
n8n_updated = True
n8n_update = False
if n8n_update:
if line == f' {old_db}\n':
line = f" {new_db}\n"
if n8n_updated:
print(f"Set n8n database depends_on: to '{new_db}' "
f"from '{old_db}' in {compose_file}...")
f.write(line)
except Exception as e:
print(f"Exception: Update n8n database settings in {compose_file}: {e}")
def main():
# Name, file and version information
global name
name = info.get('name', 'placeholder')
file = info.get('file', 'placeholder.py')
version = info.get('version', (-1, -1, -1))
print(f"""{name} version: {'.'.join(map(str, version))}""")
# Profile, environment and operation arguments
global open_webui_all_profiles
ollama_profiles = ['cpu', 'gpu-nvidia', 'gpu-amd']
n8n_profiles = ["n8n", "n8n-all"]
n8n_all_profiles = n8n_profiles + ['ai-all']
open_webui_utils_profiles = ['open-webui-mcpo', 'open-webui-pipe']
open_webui_profiles = ['open-webui', 'open-webui-all']
open_webui_all_profiles = open_webui_profiles + n8n_all_profiles
agent_all_profiles = open_webui_all_profiles + ['opencode']
server_profiles = ['supabase', 'flowise', 'searxng', 'langfuse', 'neo4j', 'caddy']
profiles = agent_all_profiles + open_webui_utils_profiles + server_profiles + ollama_profiles
operations = ['stop', 'stop-ollama', 'start', 'pause', 'unpause', 'update', 'install']
environments = ['private', 'public']
parser = argparse.ArgumentParser(
prog=f'{file}',
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent(f'''\
{info.get("description")}
With {file}, you can install, start, stop, pause, update or install
{name} with specified profile arguments (functional modules) and environment.
___________________________
Syntax:
python {file} [--profile <arguments...>] [--environment <argument>] [--operation <argument>]
Example commands:
- Install profile arguments n8n and opencode...
...with Ollama running in the Host:
>python {file} --profile n8n opencode
...with Ollama runing in Docker using CPU:
>python {file} --profile n8n opencode cpu
...using GPU and in public (production) environment:
>python {file} --profile n8n opencode --environment public
- Perform stop (start, pause, unpause) suite operation:
>python {file} --profile n8n opencode cpu --operation stop
- Perform suite operation to update all modules and restart:
>python {file} --operation update
- Perform suite operation to install all functional modules and start:
>python {file} --operation install
'''),
epilog=textwrap.dedent(f'''\
- Title: {info.get("title")}
- File: {info.get("file")}
- Author: {info.get("author")}
- Author URL: {info.get("author_url")}
- Repository: {info.get("repository")}
- Report Issues: {info.get("issues")}
- License: {info.get("license")}
- Copyright: {info.get("copyright")}
'''))
parser.add_argument('-p', '--profile', type=str, nargs='+', choices=profiles,
help='Docker Compose Profile arguments for functional modules and Ollama'
'CPU/GPU options (default: open-webui - with Ollama running on Host)')
parser.add_argument('-e', '--environment', type=str, choices=environments, default='private',
help='Environment arguments used by Docker Compose to expose '
'or restrict network communication ports (default: private)')
parser.add_argument('-o', '--operation', type=str, choices=operations,
help='Docker container, volumes and image management arguments '
'along with argument to stop Ollama running on Host.')
args = parser.parse_args()
# Detect platform
global system
system = platform.system()
if system == "Windows":
print("""Detected Windows platform...""")
elif system == "Darwin": # macOS
print("""Detected macOS platform...""")
else: # Linux and other Unix-like systems
print("""Detected Linux/Unix platform...""")
# Check prerequisites
if not check_prerequisites():
sys.exit(1)
# Load working environment variables
env_vars = get_dotenv_vars()
if not env_vars:
sys.exit(1)
# Detect default profile - no arguments specified
default_profile = False if args.profile else True
# Set default projects path in working environment variables
if any(p for p in args.profile if p in agent_all_profiles):
if not env_vars['PROJECTS_PATH']:
env_vars['PROJECTS_PATH'] = normalize_path(os.path.join('~', 'projects'))
# Check Ollama status when running Ollama in the Host
ollama_in_host = default_profile or not \
any(profile for profile in args.profile if profile in ollama_profiles)
if ollama_in_host:
global ollama_found, ollama_app, ollama_exe, attempted_launch
ollama_found = False
attempted_launch = False
if system == "Windows":
ollama_app = 'ollama.exe'
ollama_exe = os.path.join(os.path.expanduser('~'), 'AppData\\Local\\Programs\\Ollama', ollama_app)
ollama_found = os.path.exists(ollama_exe)
else: # Unix-based systems (Linux, macOS)
ollama_app = 'ollama'
for ollama_path in ['/bin', '/usr/local/bin', '/usr/bin']:
ollama_exe = os.path.join(ollama_path, ollama_app)
if os.path.exists(ollama_exe):
ollama_found = True
break
if not ollama_found:
ollama_path = dotenv.get_key(env_file, 'OLLAMA_PATH')
if ollama_path is None:
ollama_path = os.environ.get('OLLAMA_PATH')
if ollama_path is not None:
ollama_exe = os.path.normpath(ollama_path)
ollama_app = os.path.basename(ollama_exe)
ollama_found = os.path.exists(ollama_exe)
check_ollama_process(args.operation)
# Process operation argument
build = False
if args.operation:
status = None
if os.path.exists('.operation'):
with open('.operation', 'r') as f:
status = f.readline()
if args.operation == 'stop-ollama':
args.operation = "stop"
if args.operation == status:
if status == 'stop':
insert = "Stopped"
else:
insert = "Paused" if status == 'pause' else "Started"
print(f"""{name} is already {insert} - exiting...""")
sys.exit(0)
elif args.operation == 'unpause' and not status == 'pause':
print(f"""{name} cannot unpause as it is not paused - exiting...""")
sys.exit(0)
if default_profile:
args.profile = ['ai-all']
build = args.operation in ['update', 'install']
if build:
install = args.operation == 'install'
if args.operation == 'update':
user_confirm = input(textwrap.dedent(f"""\
Performing an {name} update can impact its integrity.
Consider backing up your data to enable rollback.
[Type 'Got-It' to continue]:
"""))
if len(user_confirm) == 0 or user_confirm.lower() != 'got-it':
print(f"""Received [{user_confirm}].""") if user_confirm else None
print(f"""{name} update was not confirmed - exiting...""")
sys.exit(0)
args.operation = 'pull'
insert = "Installing" if install == 'install' else "Updating"
if default_profile:
print(f"""{insert} all container images including Ollama...""")
args.profile.extend(["cpu"])
else:
print(f"""{insert} container images for {args.profile}...""")
docker_compose_include(True, True)
destroy_ai_suite(args.profile, build, install)
operate_ai_suite(args.operation, args.profile, args.environment, env_vars)
if not build:
sys.exit(0)
os.remove('.operation') if os.path.exists('.operation') else None
# Manually set default profile argument
if default_profile:
if build:
args.profile.remove('cpu') if 'cpu' in args.profile else None
else:
args.profile = ['open-webui']
# Setup Supabase
if any(p for p in args.profile if p == 'supabase'):
if not any(p for p in args.profile if p in n8n_all_profiles):
print(f"""Profile argument 'supabase' requires argument in {n8n_all_profiles}
- removing 'supabase'...""")
args.profile.remove('supabase')
supabase = \
any(p for p in args.profile if p in ['supabase', 'ai-all'])
if supabase:
args.profile.remove('supabase') if 'supabase' in args.profile else None
clone_supabase_repo()
convert_supabase_pooler_line_endings()
if any(p for p in args.profile if p in n8n_all_profiles):
env_vars['POSTGRES_HOST'] = "db" if supabase else "postgres"
configure_n8n_database_settings(supabase)
if supabase:
prepare_supabase_env(env_vars)
elif 'langfuse' in args.profile:
print("""Profile argument 'langfuse' requires Supabase - removing 'langfuse'...'""")
args.profile.remove('langfuse')
# Generate SearXNG secret key and check docker-compose.yml
if any(p for p in args.profile if p in ['searxng', 'ai-all']):
generate_searxng_secret_key()
check_and_fix_docker_compose_for_searxng()
# Setup Open WebUI Functions and Tools Filesystem repos
open_webui = \
any(p for p in args.profile if p in open_webui_all_profiles)
if open_webui:
clone_open_webui_functions_repos()
clone_open_webui_tools_filesystem_repo()
prepare_open_webui_tools_filesystem_env(env_vars)
# Add or remove Supabase and Filesystem include compose.yml in docker-compose.yml
docker_compose_include(supabase, open_webui)
# Stop and remove AI-Suite containers
if not build:
destroy_ai_suite(args.profile, False, False)
# Load environment variables
load_dotenv_vars(env_vars)
# Start Supabase first
if supabase:
start_supabase(args.environment, build)
# Give Supabase some time to initialize
print("""Waiting for Supabase to initialize...""")
time.sleep(10)
# Start Open WebUI Tools Filesystem
if open_webui:
start_open_webui_tools_filesystem(args.environment, build)
time.sleep(1)
# Unset Compose ignore orphans variable
key='COMPOSE_IGNORE_ORPHANS'
if key in os.environ:
del os.environ[key]
# Check if open-webui-mcpo specified with required profile arguments, else remove open-webui-mcpo
if any(p for p in args.profile if p == 'open-webui-mcpo'):
if not any(p for p in args.profile if p in open_webui_all_profiles):
print(f"""Profile argument 'open-webui-mcpo' requires argument in
{open_webui_all_profiles} - removing 'open-webui-mcpo'...""")
args.profile.remove('open-webui-mcpo')
# Check if open-webui-pipe specified with required profile arguments, else remove open-webui-pipe
if any(p for p in args.profile if p == 'open-webui-pipe'):
if not any(p for p in args.profile if p in open_webui_all_profiles):
print(f"""Profile argument 'open-webui-pipe' requires argument in
{open_webui_all_profiles} - removing 'open-webui-pipe'...""")
args.profile.remove('open-webui-pipe')
# Check if profile arguments n8n and open-webui specified, remove redundant open-webui
if any(p for p in args.profile if p == 'n8n'):
if any(p for p in args.profile if p == 'open-webui'):
print("""Profiles arguments 'n8n' and 'open-webui' detected - removing 'open-webui'...""")
args.profile.remove('open-webui')
# Check if more than one Ollama CPU/GPU argument specified, use first argument
if any(p for p in args.profile if p in ollama_docker_profiles):
first_argument = False
for profile_arg in ollama_docker_profiles:
if not first_argument:
if any(p for p in args.profile if p == profile_arg):
print(f"""{name} will use Ollama CPU/GPU profile argument '{profile_arg}'...""")
first_argument = True
else:
args.profile.remove(profile_arg)
# Then start the AI-Suite services
start_ai_suite(args.profile, args.environment, build)
with open('.operation', 'w') as f:
f.write('start')
if __name__ == "__main__":
main()