Skip to content

Commit 32dea9d

Browse files
authored
setup: fix Jetson runtime setup and lifecycle helpers
Fix Jetson setup/runtime deployment after issue #65. - Patch protected geniepod.toml through sudo-safe temp files and fail setup if config patching fails. - Replace masked/stale systemd unit symlinks during deploy-systemd. - Keep genie-ai-runtime as the default backend and install v1.0.0 release binaries with checksum verification. - Add start_all.sh and stop_all.sh helpers, with warmup units queued using systemctl --no-block. Closes #65.
1 parent bd4c835 commit 32dea9d

5 files changed

Lines changed: 525 additions & 133 deletions

File tree

Makefile

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ deploy-config:
107107

108108
deploy-systemd:
109109
scp deploy/systemd/*.service deploy/systemd/*.target $(JETSON_TARGET):/tmp/
110-
ssh $(JETSON_TARGET) 'sudo cp /tmp/genie-*.service /tmp/homeassistant.service /tmp/geniepod*.target /etc/systemd/system/ 2>/dev/null; \
110+
ssh $(JETSON_TARGET) 'for unit in /tmp/genie-*.service /tmp/homeassistant.service /tmp/geniepod*.target; do \
111+
sudo install -m 0644 "$$unit" "/etc/systemd/system/$$(basename "$$unit")"; \
112+
done; \
111113
sudo systemctl daemon-reload'
112114

113115
deploy-docker:
@@ -118,12 +120,12 @@ deploy-docker:
118120

119121
deploy-setup:
120122
scp deploy/setup-jetson.sh $(JETSON_TARGET):/tmp/
121-
scp deploy/scripts/genie-wake-listen.py deploy/scripts/genie-wakeword.py deploy/scripts/detect-audio-device.sh deploy/scripts/genie-restart-all.sh deploy/scripts/genie-audio-init $(JETSON_TARGET):/tmp/
123+
scp deploy/scripts/genie-wake-listen.py deploy/scripts/genie-wakeword.py deploy/scripts/detect-audio-device.sh deploy/scripts/genie-restart-all.sh deploy/scripts/start_all.sh deploy/scripts/stop_all.sh deploy/scripts/genie-audio-init $(JETSON_TARGET):/tmp/
122124
ssh $(JETSON_TARGET) 'sudo cp /tmp/setup-jetson.sh $(INSTALL_DIR)/setup-jetson.sh && \
123125
sudo chmod +x $(INSTALL_DIR)/setup-jetson.sh && \
124126
sudo mkdir -p $(INSTALL_DIR)/bin && \
125-
sudo cp /tmp/genie-wake-listen.py /tmp/genie-wakeword.py /tmp/detect-audio-device.sh /tmp/genie-restart-all.sh /tmp/genie-audio-init $(INSTALL_DIR)/bin/ && \
126-
sudo chmod +x $(INSTALL_DIR)/bin/genie-wake-listen.py $(INSTALL_DIR)/bin/genie-wakeword.py $(INSTALL_DIR)/bin/detect-audio-device.sh $(INSTALL_DIR)/bin/genie-restart-all.sh $(INSTALL_DIR)/bin/genie-audio-init'
127+
sudo cp /tmp/genie-wake-listen.py /tmp/genie-wakeword.py /tmp/detect-audio-device.sh /tmp/genie-restart-all.sh /tmp/start_all.sh /tmp/stop_all.sh /tmp/genie-audio-init $(INSTALL_DIR)/bin/ && \
128+
sudo chmod +x $(INSTALL_DIR)/bin/genie-wake-listen.py $(INSTALL_DIR)/bin/genie-wakeword.py $(INSTALL_DIR)/bin/detect-audio-device.sh $(INSTALL_DIR)/bin/genie-restart-all.sh $(INSTALL_DIR)/bin/start_all.sh $(INSTALL_DIR)/bin/stop_all.sh $(INSTALL_DIR)/bin/genie-audio-init'
127129

128130
# ── Alternate LLM runtime: genie-ai-runtime (issue #54) ─────────
129131
#

crates/genie-core/tests/tool_dispatch_test.rs

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,37 +115,164 @@ fn setup_script_warns_about_missing_audio_helper() {
115115
);
116116
}
117117

118-
/// Verify the Jetson restart helper script is syntactically valid.
118+
/// Verify LLM backend auto-fallback can patch a root-owned config and fails loudly.
119119
#[test]
120-
fn jetson_restart_script_is_valid_shell() {
121-
let path = workspace_root().join("deploy/scripts/genie-restart-all.sh");
122-
assert!(path.exists(), "restart helper script should exist");
120+
fn setup_script_privileged_llm_backend_patch_is_checked() {
121+
let path = workspace_root().join("deploy/setup-jetson.sh");
122+
let contents = std::fs::read_to_string(&path).unwrap();
123123

124-
let output = std::process::Command::new("bash")
125-
.args(["-n", path.to_str().unwrap()])
126-
.output()
127-
.expect("failed to run bash -n");
124+
assert!(
125+
contents.contains("CONFIGURED_BACKEND=\"$(sudo awk"),
126+
"setup script should read the configured LLM backend through sudo"
127+
);
128+
assert!(
129+
contents.contains("if ! sudo awk -v nb=\"$new_backend\" -v nu=\"$new_unit\""),
130+
"setup script should read the chmod 600 root-owned config through sudo"
131+
);
132+
assert!(
133+
contents.contains("sudo mktemp /tmp/geniepod.toml."),
134+
"setup script should create a root-owned temp file for the patched config"
135+
);
136+
assert!(
137+
contents.contains("ERROR: failed to rewrite $cfg for patching"),
138+
"setup script should report failed config rewrites"
139+
);
140+
assert!(
141+
contents.contains("| sudo tee \"$tmp\" > /dev/null"),
142+
"setup script should write the patched temp file through sudo tee"
143+
);
144+
assert!(
145+
contents.contains("ERROR: failed to install patched $cfg"),
146+
"setup script should report failed config installs"
147+
);
148+
assert!(
149+
contents.contains("sudo rm -f \"$tmp\""),
150+
"setup script should clean up the root-owned temp file through sudo"
151+
);
152+
assert!(
153+
contents.contains("Installing genie-ai-runtime now; this is the default backend"),
154+
"setup script should install the default runtime during normal setup"
155+
);
156+
assert!(
157+
contents.contains("Downloading prebuilt runtime assets"),
158+
"setup script should download the default runtime from release assets"
159+
);
160+
assert!(
161+
contents.contains("SHA256SUMS"),
162+
"setup script should download release checksums"
163+
);
164+
assert!(
165+
contents.contains("sha256sum -c"),
166+
"setup script should verify downloaded runtime checksums"
167+
);
168+
assert!(
169+
contents.contains("jetson-llm-server-v1.0.0-aarch64-unknown-linux-gnu"),
170+
"setup script should document the required server release asset"
171+
);
172+
assert!(
173+
!contents.contains("git clone --branch \"$tag\""),
174+
"setup script should not clone the runtime repo during normal install"
175+
);
176+
assert!(
177+
!contents.contains("cmake --build build"),
178+
"setup script should not build the runtime from source during setup"
179+
);
180+
assert!(
181+
!contents.contains("Auto-falling back to llama.cpp"),
182+
"setup script should not silently downgrade the default backend to llama.cpp"
183+
);
184+
assert!(
185+
contents.contains(
186+
"if ! patch_services_llm_backend \"genie_ai_runtime\" \"genie-ai-runtime.service\""
187+
),
188+
"genie-ai-runtime selection should check patch failure"
189+
);
190+
assert!(
191+
contents
192+
.contains("auto-fallback could not patch $CONFIG_DIR/geniepod.toml; aborting setup"),
193+
"setup should abort instead of enabling services against an unpatched config"
194+
);
195+
}
196+
197+
/// Verify the Jetson lifecycle helper scripts are syntactically valid.
198+
#[test]
199+
fn jetson_lifecycle_scripts_are_valid_shell() {
200+
for script in [
201+
"deploy/scripts/genie-restart-all.sh",
202+
"deploy/scripts/start_all.sh",
203+
"deploy/scripts/stop_all.sh",
204+
] {
205+
let path = workspace_root().join(script);
206+
assert!(path.exists(), "{script} should exist");
207+
208+
let output = std::process::Command::new("bash")
209+
.args(["-n", path.to_str().unwrap()])
210+
.output()
211+
.expect("failed to run bash -n");
212+
213+
assert!(
214+
output.status.success(),
215+
"{script} has invalid shell syntax: {}",
216+
String::from_utf8_lossy(&output.stderr)
217+
);
218+
}
219+
}
220+
221+
/// Verify the deploy pipeline copies the Jetson lifecycle helper scripts.
222+
#[test]
223+
fn makefile_deploys_lifecycle_helpers() {
224+
let path = workspace_root().join("Makefile");
225+
let contents = std::fs::read_to_string(&path).unwrap();
226+
227+
for script in ["genie-restart-all.sh", "start_all.sh", "stop_all.sh"] {
228+
assert!(
229+
contents.contains(&format!("deploy/scripts/{script}")),
230+
"Makefile should copy {script} during deploy"
231+
);
232+
assert!(
233+
contents.contains(&format!("$(INSTALL_DIR)/bin/{script}")),
234+
"Makefile should install {script} into /opt/geniepod/bin"
235+
);
236+
}
237+
}
238+
239+
/// Verify start_all follows the configured backend instead of starting both LLMs.
240+
#[test]
241+
fn start_all_uses_configured_llm_backend() {
242+
let path = workspace_root().join("deploy/scripts/start_all.sh");
243+
let contents = std::fs::read_to_string(&path).unwrap();
128244

129245
assert!(
130-
output.status.success(),
131-
"restart helper script has invalid shell syntax: {}",
132-
String::from_utf8_lossy(&output.stderr)
246+
contents.contains("Configured LLM unit"),
247+
"start_all should report the selected LLM unit"
248+
);
249+
assert!(
250+
contents.contains("read_llm_unit"),
251+
"start_all should read [services.llm].systemd_unit"
252+
);
253+
assert!(
254+
contents.contains("other_llm_units_for"),
255+
"start_all should stop the non-selected LLM backend before starting"
256+
);
257+
assert!(
258+
contents.contains("is_warmup_unit") && contents.contains("start --no-block"),
259+
"start_all should queue warmup units without blocking the lifecycle script"
133260
);
134261
}
135262

136-
/// Verify the deploy pipeline copies the Jetson restart helper script.
263+
/// Verify systemd deploy replaces stale or masked unit-file symlinks.
137264
#[test]
138-
fn makefile_deploys_restart_helper() {
265+
fn makefile_installs_systemd_units_instead_of_copying_through_symlinks() {
139266
let path = workspace_root().join("Makefile");
140267
let contents = std::fs::read_to_string(&path).unwrap();
141268

142269
assert!(
143-
contents.contains("deploy/scripts/genie-restart-all.sh"),
144-
"Makefile should copy the restart helper script during deploy"
270+
contents.contains("sudo install -m 0644 \"$$unit\""),
271+
"Makefile should replace stale/masked unit files instead of copying through symlinks"
145272
);
146273
assert!(
147-
contents.contains("$(INSTALL_DIR)/bin/genie-restart-all.sh"),
148-
"Makefile should install the restart helper into /opt/geniepod/bin"
274+
!contents.contains("sudo cp /tmp/genie-*.service"),
275+
"Makefile should not use cp for systemd units; cp follows masked-unit symlinks"
149276
);
150277
}
151278

deploy/scripts/start_all.sh

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/bin/bash
2+
# Start the deployed GeniePod stack on Jetson.
3+
4+
set -euo pipefail
5+
6+
CONFIG_FILE="${GENIEPOD_CONFIG:-/etc/geniepod/geniepod.toml}"
7+
8+
if [ "$(id -u)" -eq 0 ]; then
9+
SYSTEMCTL=(systemctl)
10+
AWK=(awk)
11+
else
12+
SYSTEMCTL=(sudo systemctl)
13+
AWK=(sudo awk)
14+
fi
15+
16+
read_llm_unit() {
17+
"${AWK[@]}" -F'"' '
18+
/^\[services\.llm\]/ { in_llm = 1; next }
19+
/^\[/ && !/^\[services\.llm\]/ { in_llm = 0 }
20+
in_llm && /^systemd_unit = / { print $2; exit }
21+
' "$CONFIG_FILE" 2>/dev/null || true
22+
}
23+
24+
normalize_unit() {
25+
local unit="$1"
26+
case "$unit" in
27+
*.service) printf '%s\n' "$unit" ;;
28+
"") printf 'genie-ai-runtime.service\n' ;;
29+
*) printf '%s.service\n' "$unit" ;;
30+
esac
31+
}
32+
33+
warmup_unit_for() {
34+
local unit="$1"
35+
case "$unit" in
36+
genie-ai-runtime.service) printf 'genie-ai-runtime-warmup.service\n' ;;
37+
genie-llm.service) printf 'genie-llm-warmup.service\n' ;;
38+
*) printf '\n' ;;
39+
esac
40+
}
41+
42+
other_llm_units_for() {
43+
local unit="$1"
44+
case "$unit" in
45+
genie-ai-runtime.service)
46+
printf '%s\n' genie-llm-warmup.service genie-llm.service
47+
;;
48+
genie-llm.service)
49+
printf '%s\n' genie-ai-runtime-warmup.service genie-ai-runtime.service
50+
;;
51+
esac
52+
}
53+
54+
unit_exists() {
55+
"${SYSTEMCTL[@]}" cat "$1" > /dev/null 2>&1
56+
}
57+
58+
is_optional_unit() {
59+
local unit="$1"
60+
case "$unit" in
61+
genie-audio.service|genie-wakeword.service|homeassistant.service)
62+
return 0
63+
;;
64+
*)
65+
return 1
66+
;;
67+
esac
68+
}
69+
70+
is_warmup_unit() {
71+
local unit="$1"
72+
case "$unit" in
73+
*-warmup.service)
74+
return 0
75+
;;
76+
*)
77+
return 1
78+
;;
79+
esac
80+
}
81+
82+
start_unit() {
83+
local unit="$1"
84+
if [ -z "$unit" ]; then
85+
return 0
86+
fi
87+
if ! unit_exists "$unit"; then
88+
if is_optional_unit "$unit"; then
89+
echo " Skip: $unit (unit not installed)"
90+
return 0
91+
fi
92+
echo " FAILED: $unit (unit not installed)"
93+
return 1
94+
fi
95+
96+
if is_warmup_unit "$unit"; then
97+
printf " Queuing %s ... " "$unit"
98+
if "${SYSTEMCTL[@]}" start --no-block "$unit"; then
99+
echo "OK"
100+
else
101+
echo "FAILED"
102+
return 1
103+
fi
104+
return 0
105+
fi
106+
107+
printf " Starting %s ... " "$unit"
108+
if ! "${SYSTEMCTL[@]}" start "$unit"; then
109+
echo "FAILED"
110+
return 1
111+
fi
112+
113+
echo "OK"
114+
}
115+
116+
raw_llm_unit="$(read_llm_unit)"
117+
configured_llm_unit="$(normalize_unit "$raw_llm_unit")"
118+
configured_warmup_unit="$(warmup_unit_for "$configured_llm_unit")"
119+
120+
UNITS=(
121+
homeassistant.service
122+
genie-audio.service
123+
genie-whisper.service
124+
genie-whisper-warmup.service
125+
"$configured_llm_unit"
126+
"$configured_warmup_unit"
127+
genie-core.service
128+
genie-governor.service
129+
genie-health.service
130+
genie-api.service
131+
genie-mqtt.service
132+
genie-wakeword.service
133+
)
134+
135+
echo "=== GeniePod start all ==="
136+
echo ""
137+
echo "Configured LLM unit: $configured_llm_unit"
138+
echo "Reloading systemd units..."
139+
"${SYSTEMCTL[@]}" daemon-reload
140+
141+
while IFS= read -r other_unit; do
142+
[ -n "$other_unit" ] || continue
143+
if unit_exists "$other_unit"; then
144+
"${SYSTEMCTL[@]}" stop "$other_unit" > /dev/null 2>&1 || true
145+
fi
146+
done < <(other_llm_units_for "$configured_llm_unit")
147+
148+
failed=()
149+
for unit in "${UNITS[@]}"; do
150+
if ! start_unit "$unit"; then
151+
failed+=("$unit")
152+
fi
153+
done
154+
155+
echo ""
156+
if [ "${#failed[@]}" -gt 0 ]; then
157+
echo "Failed units: ${failed[*]}"
158+
exit 1
159+
fi
160+
161+
echo "All available GeniePod services started."

0 commit comments

Comments
 (0)