Skip to content

Commit 210d3a4

Browse files
authored
Merge pull request #72 from adamamyl/feature/ollama-openwebui
feat: Ollama + Open WebUI — local AI stack for Linux and macOS
2 parents ea1702a + 0b5767c commit 210d3a4

8 files changed

Lines changed: 1533 additions & 17 deletions

File tree

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,112 @@ sudo /root/.local/bin/uv run -- python3 /usr/local/src/machine-setup/setup_machi
5050
| **Install Docker & Packages**| `sudo uv run -- python3 /usr/local/src/machine-setup/setup_machine.py --docker --packages` | Installs system packages and Docker. |
5151

5252

53+
## Ollama
54+
55+
Installs [Ollama](https://ollama.com) locally (bare-metal, for full GPU/CPU access) and deploys [Open WebUI](https://docs.openwebui.com) via Docker Compose. The UI connects to the host Ollama daemon through `host.docker.internal`, so model performance is unaffected by containerisation.
56+
57+
Works on **Linux** and **macOS**.
58+
59+
### Quick start
60+
61+
```bash
62+
# Linux
63+
sudo ./setup_machine.py --ollama
64+
65+
# macOS — no sudo needed (brew refuses to run as root)
66+
./setup_machine.py --ollama
67+
```
68+
69+
Open WebUI will be available at **http://localhost:3000** (or whichever port was free).
70+
71+
### How it installs Ollama
72+
73+
| Platform | Method | Service manager |
74+
| :--- | :--- | :--- |
75+
| Linux | `curl -fsSL https://ollama.com/install.sh \| sh` | systemd (`systemctl enable ollama --now`) |
76+
| macOS | `brew install ollama` | launchd (`brew services start ollama`) |
77+
78+
Installation is idempotent — re-running skips anything already in place.
79+
80+
### Open WebUI compose stack
81+
82+
The stack is written to `/opt/ollama-webui/` (Linux) or `~/.ollama-webui/` (macOS) and includes:
83+
84+
- `docker-compose.yml` — Open WebUI image, port mapping, volume mounts, env vars
85+
- `.env` — chosen ports and Google PSE credentials (mode `600`, not committed)
86+
87+
Persistent UI data is stored in a named Docker volume (`open-webui-data`).
88+
`~/pseudohome` and `~/projects` are bind-mounted read-only at `/workspace/` inside the container if they exist.
89+
The Docker socket is mounted read-only so the UI can introspect running containers.
90+
91+
### Port selection
92+
93+
Both the Ollama API port (default `11434`) and the WebUI port (default `3000`) are checked for availability before use. If a port is already bound, a free one is selected at random from a configurable range and written to the compose file and `.env`.
94+
95+
### Flags
96+
97+
| Flag | Default | Description |
98+
| :--- | :--- | :--- |
99+
| `--ollama` || Run the full Ollama + Open WebUI setup |
100+
| `--ollama-port` | `11434` | Preferred Ollama API port (auto-fallback if taken) |
101+
| `--webui-port` | `3000` | Preferred Open WebUI port (auto-fallback if taken) |
102+
| `--ollama-model` | `ministral:3b` | Model to pull after install |
103+
| `--ollama-user` | `ollama-docker` | System user owning the stack (Linux only) |
104+
| `--ollama-open-path` || Add a host path into the compose stack and open a shell |
105+
| `--ollama-terminal` || Open a shell in a sibling container with a host path mounted |
106+
| `--ollama-google-api-key` || Google PSE API key for web search |
107+
| `--ollama-google-cx` || Google PSE engine ID (cx) for web search |
108+
109+
### Accessing a project inside the container
110+
111+
To open an interactive shell in the Open WebUI container with a host directory available at `/workspace/<dirname>`:
112+
113+
```bash
114+
# via the orchestrator
115+
sudo ./setup_machine.py --ollama-terminal ~/projects/machine-setup
116+
117+
# or the standalone helper (no full setup run needed)
118+
./tools/ollama-open-terminal.sh ~/projects/machine-setup
119+
```
120+
121+
This spins up a temporary sibling container sharing the same image and named volume — the primary container is not restarted.
122+
123+
### Google web search (PSE)
124+
125+
Open WebUI can search the web via [Google Programmable Search Engine](https://programmablesearchengine.google.com/). You need two values:
126+
127+
- `GOOGLE_PSE_API_KEY` — a Google Cloud API key with the Custom Search API enabled
128+
- `GOOGLE_PSE_ENGINE_ID` — the `cx` identifier from your PSE
129+
130+
Pass them as flags, or edit `<stack_dir>/.env` after setup:
131+
132+
```bash
133+
# via flags
134+
sudo ./setup_machine.py --ollama \
135+
--ollama-google-api-key YOUR_KEY \
136+
--ollama-google-cx YOUR_CX
137+
138+
# or edit the .env directly and restart
139+
# Linux: /opt/ollama-webui/.env
140+
# macOS: ~/.ollama-webui/.env
141+
docker compose -f <stack_dir>/docker-compose.yml restart
142+
```
143+
144+
Full step-by-step setup instructions (create PSE → enable API → restrict key) are printed to the terminal at the end of every `--ollama` run.
145+
146+
### Example invocations
147+
148+
| Scenario | Command |
149+
| :--- | :--- |
150+
| Defaults (Linux) | `sudo ./setup_machine.py --ollama` |
151+
| Custom model | `sudo ./setup_machine.py --ollama --ollama-model qwen2.5-coder` |
152+
| Custom ports | `sudo ./setup_machine.py --ollama --ollama-port 11435 --webui-port 3001` |
153+
| With web search | `sudo ./setup_machine.py --ollama --ollama-google-api-key KEY --ollama-google-cx CX` |
154+
| macOS, no sudo | `./setup_machine.py --ollama` |
155+
| Open terminal | `./tools/ollama-open-terminal.sh ~/projects/machine-setup` |
156+
157+
---
158+
53159
## Virtual Env approach
54160
🚀 Orchestrator Invocation
55161

lib/constants.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,43 @@
7575

7676
# Full modern Docker suite (Matching successful installation log)
7777
DOCKER_PKGS: List[str] = [
78-
"docker-ce",
79-
"docker-ce-cli",
80-
"containerd.io",
78+
"docker-ce",
79+
"docker-ce-cli",
80+
"containerd.io",
8181
"docker-compose-plugin",
8282
"docker-buildx-plugin",
8383
"docker-ce-rootless-extras",
84+
"docker-model-plugin",
85+
]
86+
87+
# --- Ollama / OpenWebUI Configuration ---
88+
89+
# Directory where the docker-compose stack and .env are written
90+
OLLAMA_STACK_DIR: str = "/opt/ollama-webui"
91+
92+
# The system user that owns the compose stack
93+
OLLAMA_USER: str = "ollama-docker"
94+
95+
# Default Ollama API port (local, on the host)
96+
OLLAMA_DEFAULT_PORT: int = 11434
97+
98+
# Default OpenWebUI port (host side; container always listens on 8080)
99+
WEBUI_DEFAULT_PORT: int = 3000
100+
101+
# Port-search range when the preferred port is already taken
102+
OLLAMA_PORT_SEARCH_MIN: int = 11400
103+
OLLAMA_PORT_SEARCH_MAX: int = 11500
104+
WEBUI_PORT_SEARCH_MIN: int = 3001
105+
WEBUI_PORT_SEARCH_MAX: int = 3099
106+
107+
# Default model to pull after Ollama is installed (lightweight, fast)
108+
OLLAMA_DEFAULT_MODEL: str = "ministral:3b"
109+
110+
# Permanent host paths bind-mounted read-only into the OpenWebUI container.
111+
# These are expanded at runtime relative to the real user's HOME.
112+
OLLAMA_PERMA_MOUNTS: List[str] = [
113+
"pseudohome",
114+
"projects",
84115
]
85116

86117
# Firewall module:
@@ -89,4 +120,4 @@
89120
FIREWALL_CONF_DIR: str = "/etc/iptables"
90121
# iptables and ip6tables are standard, but we ensure iptables-persistent
91122
# directory structure exists for our own script's logic.
92-
FIREWALL_PACKAGES: List[str] = ["iptables", "curl"]
123+
FIREWALL_PACKAGES: List[str] = ["iptables", "curl"]

lib/installer_utils/brew_tools.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
brew_tools.py
3+
=============
4+
Homebrew package-management helpers for macOS (and Linuxbrew, if ever needed).
5+
6+
Design notes
7+
------------
8+
* Homebrew explicitly refuses to run as root. All functions accept a
9+
``brew_user`` argument and execute brew as that user via the Executor's
10+
``user=`` mechanism.
11+
* Use ``get_brew_user()`` from ``lib.platform_utils`` to obtain the correct
12+
user before calling anything here.
13+
* ``find_brew()`` checks PATH first, then the canonical Apple-Silicon and
14+
Intel install locations, so it works correctly even when called as root
15+
(where PATH may not include Homebrew's prefix).
16+
"""
17+
18+
import os
19+
import shutil
20+
from typing import Optional
21+
22+
from ..executor import Executor
23+
from ..logger import log
24+
25+
# Ordered by likelihood: Apple Silicon first, then Intel, then Linuxbrew.
26+
_BREW_CANDIDATE_PATHS: list[str] = [
27+
"/opt/homebrew/bin/brew", # Apple Silicon (M-series)
28+
"/usr/local/bin/brew", # Intel Mac
29+
"/home/linuxbrew/.linuxbrew/bin/brew", # Linuxbrew (rarely used here)
30+
]
31+
32+
33+
def find_brew() -> Optional[str]:
34+
"""
35+
Return the absolute path to the ``brew`` binary, or ``None`` if Homebrew
36+
is not installed. Checks ``PATH`` first, then known install locations.
37+
"""
38+
in_path = shutil.which("brew")
39+
if in_path:
40+
return in_path
41+
for candidate in _BREW_CANDIDATE_PATHS:
42+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
43+
return candidate
44+
return None
45+
46+
47+
def ensure_brew_installed(exec_obj: Executor, brew_user: str) -> str:
48+
"""
49+
Ensure Homebrew is installed. If not found, runs the official install
50+
script interactively as *brew_user*.
51+
52+
Returns the path to the ``brew`` binary.
53+
Raises ``RuntimeError`` if installation fails.
54+
"""
55+
existing = find_brew()
56+
if existing:
57+
log.success(f"Homebrew found at {existing}.")
58+
return existing
59+
60+
log.info("Homebrew not found — installing via official script…")
61+
exec_obj.run(
62+
'curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | bash',
63+
user=brew_user,
64+
interactive=True,
65+
)
66+
67+
installed = find_brew()
68+
if not installed:
69+
raise RuntimeError(
70+
"Homebrew installation completed but 'brew' binary still not found. "
71+
"You may need to add Homebrew to PATH manually."
72+
)
73+
log.success(f"Homebrew installed at {installed}.")
74+
return installed
75+
76+
77+
def brew_install(exec_obj: Executor, brew_user: str, *packages: str) -> None:
78+
"""
79+
Install one or more Homebrew formulae (idempotent).
80+
81+
Checks ``brew list --formula`` for each package before attempting to
82+
install, so re-running is safe and fast.
83+
"""
84+
brew = find_brew()
85+
if not brew:
86+
raise FileNotFoundError(
87+
"Homebrew not found. Run ensure_brew_installed() first."
88+
)
89+
90+
to_install: list[str] = []
91+
for pkg in packages:
92+
try:
93+
result = exec_obj.run(
94+
[brew, "list", "--formula", pkg],
95+
user=brew_user,
96+
check=False,
97+
run_quiet=True,
98+
)
99+
if result.returncode == 0:
100+
log.success(f"Brew formula already installed: {pkg}")
101+
else:
102+
to_install.append(pkg)
103+
except Exception:
104+
to_install.append(pkg)
105+
106+
if not to_install:
107+
return
108+
109+
log.info(f"Installing brew formulae: {', '.join(to_install)} …")
110+
exec_obj.run([brew, "install"] + to_install, user=brew_user)
111+
log.success(f"Installed via brew: {', '.join(to_install)}")
112+
113+
114+
def brew_service_start(exec_obj: Executor, brew_user: str, service: str) -> None:
115+
"""
116+
Start *service* via ``brew services start`` (registers with launchd).
117+
Idempotent — if already running, brew will report that and exit 0.
118+
"""
119+
brew = find_brew()
120+
if not brew:
121+
raise FileNotFoundError("Homebrew not found.")
122+
123+
log.info(f"Starting brew service: {service} …")
124+
exec_obj.run([brew, "services", "start", service], user=brew_user)
125+
log.success(f"Brew service '{service}' started (launchd registered).")
126+
127+
128+
def is_brew_service_running(exec_obj: Executor, brew_user: str, service: str) -> bool:
129+
"""
130+
Return ``True`` if *service* is currently in the 'started' state according
131+
to ``brew services info``.
132+
"""
133+
brew = find_brew()
134+
if not brew:
135+
return False
136+
try:
137+
result = exec_obj.run(
138+
[brew, "services", "info", service, "--json"],
139+
user=brew_user,
140+
check=False,
141+
run_quiet=True,
142+
)
143+
return result.returncode == 0 and '"started"' in result.stdout
144+
except Exception:
145+
return False

0 commit comments

Comments
 (0)