Skip to content

Commit c699a93

Browse files
committed
Add preflight doctor and resilient bootstrap workflow
1 parent 83a3aa8 commit c699a93

11 files changed

Lines changed: 507 additions & 59 deletions

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ Requires OrbStack (or Docker Desktop) and Plex already installed. Handles everyt
8787
curl -fsSL https://raw.githubusercontent.com/liamvibecodes/mac-media-stack-advanced/main/bootstrap.sh | bash
8888
```
8989

90+
Optional flags when running from a local clone:
91+
92+
```bash
93+
bash bootstrap.sh --media-dir /Volumes/T9/Media --install-dir ~/mac-media-stack-advanced --non-interactive
94+
```
95+
9096
<details>
9197
<summary>See it in action</summary>
9298
<br>
@@ -100,8 +106,9 @@ If you prefer to run each step yourself:
100106
```bash
101107
git clone https://github.com/liamvibecodes/mac-media-stack-advanced.git
102108
cd mac-media-stack-advanced
103-
bash scripts/setup.sh
109+
bash scripts/setup.sh # or: bash scripts/setup.sh --media-dir /Volumes/T9/Media
104110
# edit .env with VPN keys
111+
bash scripts/doctor.sh # preflight validation before first boot
105112
docker compose up -d
106113
docker compose --profile autoupdate up -d watchtower # optional auto-updates
107114
bash scripts/configure.sh
@@ -141,11 +148,18 @@ Optional music:
141148

142149
All download traffic routes through ProtonVPN (with optional NordVPN failover). Gluetun's built-in kill switch blocks traffic if the VPN drops, so your real IP is never exposed through the tunnel. Everything else uses your normal connection. All services auto-start on boot and self-heal if they go down.
143150

151+
To manually switch providers after creating `.env.nord` from `.env.nord.example`:
152+
153+
```bash
154+
bash scripts/vpn-mode.sh nord
155+
```
156+
144157
## Scripts
145158

146159
| Script | Purpose |
147160
|--------|---------|
148161
| `scripts/setup.sh` | Creates folders, generates .env, copies config templates |
162+
| `scripts/doctor.sh` | Runs preflight checks (runtime, env, compose, ports) |
149163
| `scripts/configure.sh` | Auto-configures all service connections via API |
150164
| `scripts/health-check.sh` | Full stack health diagnostic |
151165
| `scripts/install-launchd-jobs.sh` | Installs all automation as background jobs |

SETUP.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ curl -fsSL https://raw.githubusercontent.com/liamvibecodes/mac-media-stack-advan
1616

1717
It will prompt you for VPN keys, configure all services, and install automation jobs. You'll still need to do Step 7 (configure Recyclarr, Kometa, Tdarr, and Unpackerr with API keys) manually afterward.
1818

19+
To run from a local clone with custom paths:
20+
21+
```bash
22+
bash bootstrap.sh --media-dir /Volumes/T9/Media --install-dir ~/mac-media-stack-advanced
23+
```
24+
1925
---
2026

2127
## Prerequisites
@@ -68,6 +74,8 @@ cd ~
6874
git clone https://github.com/liamvibecodes/mac-media-stack-advanced.git
6975
cd mac-media-stack-advanced
7076
bash scripts/setup.sh
77+
# or:
78+
# bash scripts/setup.sh --media-dir /Volumes/T9/Media
7179
```
7280

7381
---
@@ -84,6 +92,14 @@ Fill in `WIREGUARD_PRIVATE_KEY` and `WIREGUARD_ADDRESSES` from your ProtonVPN ac
8492

8593
## Step 4: Start the Stack
8694

95+
Run preflight checks before first startup:
96+
97+
```bash
98+
bash scripts/doctor.sh
99+
```
100+
101+
Then start the stack:
102+
87103
```bash
88104
docker compose up -d
89105
bash scripts/health-check.sh
@@ -138,6 +154,7 @@ bash scripts/configure.sh
138154
```
139155

140156
This configures qBittorrent, Prowlarr (indexers), Radarr, Sonarr, and Seerr. It will print your API keys at the end. **Save them.**
157+
It also writes credentials/API keys to `~/Media/state/first-run-credentials.txt` (mode `600`).
141158

142159
---
143160

@@ -226,6 +243,12 @@ bash scripts/install-vpn-failover.sh
226243
```
227244

228245
This checks every 2 minutes and auto-switches between Proton and Nord after 3 consecutive failures.
246+
Use `docker-compose.nord-fallback.yml` only for Nord mode; Proton remains the default compose path.
247+
248+
Check current provider anytime:
249+
```bash
250+
bash scripts/vpn-mode.sh status
251+
```
229252

230253
---
231254

bootstrap.sh

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,64 @@ YELLOW='\033[1;33m'
1010
CYAN='\033[0;36m'
1111
NC='\033[0m'
1212

13+
MEDIA_DIR="$HOME/Media"
14+
INSTALL_DIR="$HOME/mac-media-stack-advanced"
15+
NON_INTERACTIVE=false
16+
17+
usage() {
18+
cat <<EOF
19+
Usage: bash bootstrap.sh [OPTIONS]
20+
21+
Options:
22+
--media-dir DIR Media root path (default: ~/Media)
23+
--install-dir DIR Repo install directory (default: ~/mac-media-stack-advanced)
24+
--non-interactive Skip interactive prompts (manual Seerr wiring required)
25+
--help Show this help message
26+
27+
Examples:
28+
bash bootstrap.sh
29+
bash bootstrap.sh --media-dir /Volumes/T9/Media
30+
bash bootstrap.sh --media-dir /Volumes/T9/Media --non-interactive
31+
EOF
32+
}
33+
34+
while [[ $# -gt 0 ]]; do
35+
case "$1" in
36+
--media-dir)
37+
if [[ $# -lt 2 || "$2" == --* ]]; then
38+
echo "Missing value for --media-dir"
39+
exit 1
40+
fi
41+
MEDIA_DIR="$2"
42+
shift 2
43+
;;
44+
--install-dir)
45+
if [[ $# -lt 2 || "$2" == --* ]]; then
46+
echo "Missing value for --install-dir"
47+
exit 1
48+
fi
49+
INSTALL_DIR="$2"
50+
shift 2
51+
;;
52+
--non-interactive)
53+
NON_INTERACTIVE=true
54+
shift
55+
;;
56+
--help|-h)
57+
usage
58+
exit 0
59+
;;
60+
*)
61+
echo "Unknown option: $1"
62+
usage
63+
exit 1
64+
;;
65+
esac
66+
done
67+
68+
MEDIA_DIR="${MEDIA_DIR/#\~/$HOME}"
69+
INSTALL_DIR="${INSTALL_DIR/#\~/$HOME}"
70+
1371
echo ""
1472
echo "======================================="
1573
echo " Mac Media Stack Installer (Advanced)"
@@ -51,6 +109,27 @@ detect_running_runtime() {
51109
fi
52110
}
53111

112+
wait_for_service() {
113+
local name="$1"
114+
local url="$2"
115+
local max_attempts="${3:-45}"
116+
local attempt=0
117+
local status
118+
119+
while [[ $attempt -lt $max_attempts ]]; do
120+
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$url" 2>/dev/null || true)
121+
if [[ "$status" =~ ^(200|301|302|401|403)$ ]]; then
122+
echo -e " ${GREEN}OK${NC} $name is reachable"
123+
return 0
124+
fi
125+
sleep 2
126+
attempt=$((attempt + 1))
127+
done
128+
129+
echo -e " ${YELLOW}WARN${NC} $name is not reachable yet (continuing anyway)"
130+
return 1
131+
}
132+
54133
INSTALLED_RUNTIME=$(detect_installed_runtime)
55134

56135
if ! docker info &>/dev/null; then
@@ -85,10 +164,12 @@ if ! command -v git &>/dev/null; then
85164
exit 1
86165
fi
87166

167+
echo ""
168+
echo "Install dir: $INSTALL_DIR"
169+
echo "Media dir: $MEDIA_DIR"
88170
echo ""
89171

90172
# Clone
91-
INSTALL_DIR="$HOME/mac-media-stack-advanced"
92173
if [[ -d "$INSTALL_DIR" ]]; then
93174
echo -e "${YELLOW}Note:${NC} $INSTALL_DIR already exists. Pulling latest..."
94175
if ! git -C "$INSTALL_DIR" pull --ff-only; then
@@ -107,41 +188,65 @@ echo ""
107188

108189
# Setup
109190
echo -e "${CYAN}Running setup...${NC}"
110-
bash scripts/setup.sh
191+
bash scripts/setup.sh --media-dir "$MEDIA_DIR"
111192

112193
echo ""
113194

114195
# VPN keys
115196
if grep -q "your_wireguard_private_key_here" .env 2>/dev/null; then
116-
echo -e "${CYAN}VPN Configuration${NC}"
117-
echo ""
118-
read -s -p " WireGuard Private Key: " vpn_key
119-
echo ""
120-
read -p " WireGuard Address (e.g. 10.2.0.2/32): " vpn_addr
121-
122-
if [[ -n "$vpn_key" && -n "$vpn_addr" ]]; then
123-
sed -i '' "s|WIREGUARD_PRIVATE_KEY=.*|WIREGUARD_PRIVATE_KEY=$vpn_key|" .env
124-
sed -i '' "s|WIREGUARD_ADDRESSES=.*|WIREGUARD_ADDRESSES=$vpn_addr|" .env
125-
echo -e " ${GREEN}VPN keys saved${NC}"
197+
if [[ "$NON_INTERACTIVE" == true ]]; then
198+
echo -e "${YELLOW}WARN${NC} Non-interactive mode: VPN placeholders still present in .env"
199+
echo " Update WIREGUARD_PRIVATE_KEY and WIREGUARD_ADDRESSES before using the stack."
126200
else
127-
echo -e " ${YELLOW}Skipped.${NC} Edit .env manually: open -a TextEdit $INSTALL_DIR/.env"
201+
echo -e "${CYAN}VPN Configuration${NC}"
202+
echo ""
203+
read -s -p " WireGuard Private Key: " vpn_key
204+
echo ""
205+
read -p " WireGuard Address (e.g. 10.2.0.2/32): " vpn_addr
206+
207+
if [[ -n "$vpn_key" && -n "$vpn_addr" ]]; then
208+
sed -i '' "s|WIREGUARD_PRIVATE_KEY=.*|WIREGUARD_PRIVATE_KEY=$vpn_key|" .env
209+
sed -i '' "s|WIREGUARD_ADDRESSES=.*|WIREGUARD_ADDRESSES=$vpn_addr|" .env
210+
echo -e " ${GREEN}VPN keys saved${NC}"
211+
else
212+
echo -e " ${YELLOW}Skipped.${NC} Edit .env manually: open -a TextEdit $INSTALL_DIR/.env"
213+
fi
128214
fi
129215
fi
130216

131217
echo ""
132218

219+
# Preflight
220+
echo -e "${CYAN}Running preflight checks...${NC}"
221+
if ! bash scripts/doctor.sh --media-dir "$MEDIA_DIR"; then
222+
echo ""
223+
echo -e "${RED}Preflight checks failed.${NC} Fix the FAIL items above, then re-run bootstrap."
224+
exit 1
225+
fi
226+
227+
echo ""
228+
133229
# Start stack
134230
echo -e "${CYAN}Starting media stack (first run downloads ~3-5 GB)...${NC}"
135231
echo ""
136232
docker compose up -d
137233

138234
echo ""
139-
echo "Waiting 30 seconds for services to initialize..."
140-
sleep 30
235+
echo "Waiting for core services..."
236+
wait_for_service "qBittorrent" "http://localhost:8080" || true
237+
wait_for_service "Prowlarr" "http://localhost:9696" || true
238+
wait_for_service "Radarr" "http://localhost:7878" || true
239+
wait_for_service "Sonarr" "http://localhost:8989" || true
240+
wait_for_service "Seerr" "http://localhost:5055" || true
241+
wait_for_service "Tdarr" "http://localhost:8265" || true
141242

142243
# Configure
143244
echo ""
144-
bash scripts/configure.sh
245+
if [[ "$NON_INTERACTIVE" == true ]]; then
246+
bash scripts/configure.sh --non-interactive
247+
else
248+
bash scripts/configure.sh
249+
fi
145250

146251
# Install automation
147252
echo ""
@@ -157,10 +262,12 @@ echo " Seerr: http://localhost:5055"
157262
echo " Plex: http://localhost:32400/web"
158263
echo " Tdarr: http://localhost:8265"
159264
echo ""
265+
echo " Media location: $MEDIA_DIR"
266+
echo ""
160267
echo " Remaining manual steps:"
161-
echo " 1. Set up Plex libraries (Movies: ~/Media/Movies, TV: ~/Media/TV Shows)"
162-
echo " 2. Edit ~/Media/config/recyclarr/recyclarr.yml with your API keys"
163-
echo " 3. Edit ~/Media/config/kometa/config.yml with Plex token + TMDB key"
268+
echo " 1. Set up Plex libraries (Movies: $MEDIA_DIR/Movies, TV: $MEDIA_DIR/TV Shows)"
269+
echo " 2. Edit $MEDIA_DIR/config/recyclarr/recyclarr.yml with your API keys"
270+
echo " 3. Edit $MEDIA_DIR/config/kometa/config.yml with Plex token + TMDB key"
164271
echo " 4. Update .env with Unpackerr API keys, then: docker compose restart unpackerr"
165272
echo " 5. Configure Tdarr at http://localhost:8265"
166273
echo ""

scripts/auto-heal.sh

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,25 @@ fi
2525
HEALED=0
2626

2727
# Check VPN
28-
vpn_ip=$(docker exec gluetun sh -c 'wget -qO- --timeout=5 https://ipinfo.io/ip' 2>/dev/null)
29-
local_ip=$(curl -s --max-time 5 https://ipinfo.io/ip 2>/dev/null)
28+
vpn_ip=$(docker exec gluetun sh -lc 'cat /tmp/gluetun/ip 2>/dev/null || true' 2>/dev/null)
29+
vpn_iface=$(docker exec gluetun sh -lc 'ls /sys/class/net 2>/dev/null | grep -E "^(tun|wg)[0-9]+$" | head -1' 2>/dev/null)
30+
vpn_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' gluetun 2>/dev/null || true)
3031

31-
if [[ -z "$vpn_ip" || "$vpn_ip" == "$local_ip" ]]; then
32-
log "WARN: VPN issue. Restarting gluetun..."
32+
if [[ -z "$vpn_ip" || -z "$vpn_iface" || "$vpn_health" == "unhealthy" ]]; then
33+
log "WARN: VPN issue (health=${vpn_health:-unknown}, ip=${vpn_ip:-none}, iface=${vpn_iface:-none}). Restarting gluetun..."
3334
docker restart gluetun >> "$LOG" 2>&1
3435
sleep 15
35-
vpn_ip=$(docker exec gluetun sh -c 'wget -qO- --timeout=5 https://ipinfo.io/ip' 2>/dev/null)
36-
if [[ -n "$vpn_ip" && "$vpn_ip" != "$local_ip" ]]; then
37-
log "OK: VPN recovered (IP: $vpn_ip)"
36+
vpn_ip=$(docker exec gluetun sh -lc 'cat /tmp/gluetun/ip 2>/dev/null || true' 2>/dev/null)
37+
vpn_iface=$(docker exec gluetun sh -lc 'ls /sys/class/net 2>/dev/null | grep -E "^(tun|wg)[0-9]+$" | head -1' 2>/dev/null)
38+
vpn_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' gluetun 2>/dev/null || true)
39+
if [[ -n "$vpn_ip" && -n "$vpn_iface" && "$vpn_health" != "unhealthy" ]]; then
40+
log "OK: VPN recovered (IP: $vpn_ip, iface=$vpn_iface, health=${vpn_health:-unknown})"
3841
else
39-
log "ERROR: VPN still down after restart"
42+
log "ERROR: VPN still down after restart (health=${vpn_health:-unknown}, ip=${vpn_ip:-none}, iface=${vpn_iface:-none})"
4043
fi
4144
((HEALED++))
4245
else
43-
log "OK: VPN active (IP: $vpn_ip)"
46+
log "OK: VPN active (IP: $vpn_ip, iface=$vpn_iface, health=${vpn_health:-unknown})"
4447
fi
4548

4649
# Check containers

0 commit comments

Comments
 (0)