diff --git a/deploy.sh b/deploy.sh index 9fe99af..1c76490 100644 --- a/deploy.sh +++ b/deploy.sh @@ -20,6 +20,23 @@ if [ ! -f "$APP_DIR/secrets.env" ]; then exit 1 fi +# Notify Telegram directly on completion/failure (curl, not the app): when this +# script is triggered by the /deploy bot command (garden/bot.py), it restarts +# the very process that queued it, so that process can't reliably report back. +set -a +# shellcheck source=/dev/null +source "$APP_DIR/secrets.env" +set +a + +_notify() { + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ] || return 0 + curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=$1" \ + > /dev/null || true +} +trap '_notify "🌱 Garden: Deploy FAILED β€” check deploy.log / journalctl -u garden-agent on the VM."' ERR + # uv installs to ~/.local/bin; non-login shells may not have it on PATH # shellcheck source=/dev/null [ -f "$HOME/.local/bin/env" ] && source "$HOME/.local/bin/env" @@ -94,3 +111,4 @@ sudo systemctl is-active --quiet ecowitt-bridge && echo " ecowitt-bridg echo "" echo "==> Deploy complete." +_notify "🌱 Garden: Deploy complete βœ… (garden-agent restarted)." diff --git a/docs/telegram.md b/docs/telegram.md index 1532fb8..4d2fd37 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -80,7 +80,7 @@ No further input needed β€” the Telegram code is already written. --- -## 5. Inbound bot commands (/bed1, /beds, /weather, /air, /brief) +## 5. Inbound bot commands (/bed1, /beds, /weather, /air, /brief, /deploy) The bot can also answer commands on demand β€” tap `/bed4` in Telegram and get a summary of Bed 4's moisture, battery, and crops back within a second. This needs @@ -118,8 +118,40 @@ the message box) to see all commands, or type them directly: | `/weather` | Today's forecast + current conditions | | `/air` | VPD, dew point / frost risk, feels-like | | `/brief` | Sends the morning brief immediately (bypasses the 7am schedule) | +| `/deploy` | Runs `deploy.sh` on the VM: git pull, restart services. See below. | | `/help` | Lists all available commands | +### `/deploy` + +Runs `deploy.sh` on the VM β€” the same script you'd otherwise SSH in and run by +hand after pushing to `main`. Because `deploy.sh` restarts `garden-agent` +itself partway through, `garden/bot.py` launches it as an independent +transient systemd unit (`systemd-run --unit=garden-deploy --collect`) instead +of a plain subprocess β€” a plain child process would share this service's +cgroup and get killed by that restart before finishing the remaining steps +(cron/backup timers, webhook re-registration, status summary). The bot +replies immediately ("Deploy started…"); `deploy.sh` itself posts a "Deploy +complete βœ…" or "Deploy FAILED" message straight to Telegram (via `curl`, +using the same `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` from `secrets.env`) +once it finishes, since the process that queued it doesn't survive to report +back. + +**One-time VM setup required**: `deploy.sh`'s `sudo` calls (`systemctl +restart`/`enable`/`reset-failed`, `tee` into `/etc/systemd/system/`) prompt +for a password when run interactively β€” fine over SSH, but there's no +terminal to answer that prompt when Telegram triggers it. Add passwordless +sudo for your deploy user (`sudo visudo -f /etc/sudoers.d/garden-deploy`): + +``` +your_vm_user ALL=(root) NOPASSWD: /usr/bin/systemctl, /usr/bin/tee, /usr/bin/systemd-run +``` + +**Security note**: this is a materially bigger capability than the other +commands β€” anyone who can message the authorized `TELEGRAM_CHAT_ID` can +trigger a `git pull` of whatever is on `main` plus a root-level systemd +restart. The existing owner-chat-id check in `garden/bot.py` is the only +gate, same as every other command; make sure that chat is one you trust. + ### How it works Telegram POSTs each command to `/api/telegram` on the running app (`garden/main.py`). @@ -141,3 +173,9 @@ no separate data path to keep in sync. ```bash curl -s "https://api.telegram.org/bot/getWebhookInfo" | python3 -m json.tool ``` +- `/deploy` replied but nothing happened / no completion message: check + `sudo journalctl -u garden-deploy -n 100` for the transient unit's own + output, and `cat deploy.log` in the project directory (deploy.sh's stdout/ + stderr are appended there when launched this way). A "Failed to start the + deploy" reply means `sudo systemd-run` itself couldn't launch β€” usually the + passwordless-sudo entry above is missing. diff --git a/garden/agent/rules.py b/garden/agent/rules.py index 849f599..0cc3446 100644 --- a/garden/agent/rules.py +++ b/garden/agent/rules.py @@ -35,14 +35,21 @@ def _iso_now() -> str: def _bed_dry_threshold(bed: dict, fallback: float) -> float: """ - The moisture % below which `bed` is considered dry: its self-learned band - minimum when there's enough history, else its crop-derived band minimum, - else `fallback` (the flat config.yaml threshold). + The moisture % below which `bed` is actually considered dry: its band + minimum (self-learned when there's enough history, else crop-derived) + minus derived.near_dry_margin(), else `fallback` (the flat config.yaml + threshold) when there's no band at all. Mirrors garden.main._effective_bed_band so the "time to water" alert agrees with the dashboard's Dry/OK/Wet chip for the same bed β€” without this, a bed with unusually loose or compacted soil could show "Dry" on - the dashboard while never tripping (or always tripping) this alert. + the dashboard while never tripping (or always tripping) this alert. The + margin subtraction matters too: the dashboard's "Soil moisture vs. band" + chart only labels a bed "Dry" once it's past that margin below band + min β€” anything closer is "Drying" (still effectively OK). Without the + same margin here, this alert fired on every dip into "Drying" territory, + re-arming every cooldown window even for a bed sitting 1-2 points under + its threshold. """ moist_key = bed.get("sensors", {}).get("soil_moisture") plants = bed.get("plants", []) @@ -66,7 +73,9 @@ def _bed_dry_threshold(bed: dict, fallback: float) -> float: else: band = derived.bed_moisture_band(plants, cfg.crops) - return band[0] if band else fallback + if band is None: + return fallback + return band[0] - derived.near_dry_margin(band) def check_soil_moisture_low() -> list[RuleResult]: diff --git a/garden/bot.py b/garden/bot.py index 4734ffd..419b977 100644 --- a/garden/bot.py +++ b/garden/bot.py @@ -1,5 +1,5 @@ """ -bot.py β€” Inbound Telegram bot commands (/bed1, /beds, /weather, /air, /brief, /help). +bot.py β€” Inbound Telegram bot commands (/bed1, /beds, /weather, /air, /brief, /deploy, /help). Counterpart to telegram.py (outbound-only). Telegram delivers each command as a webhook POST to /api/telegram (see garden/main.py), which calls handle_update() @@ -21,7 +21,9 @@ from __future__ import annotations import logging +import subprocess from datetime import datetime, timezone +from pathlib import Path from typing import Any import httpx @@ -33,6 +35,9 @@ log = logging.getLogger("garden.bot") +_APP_DIR = Path(__file__).resolve().parent.parent # garden-agent/ (holds deploy.sh) +_DEPLOY_LOG = _APP_DIR / "deploy.log" + _STATUS_WORD = { "ok": "Good", "dry": "Dry", "wet": "Wet", "cold": "Cold", "heat": "Heat stress", "unknown": "Unknown", @@ -43,6 +48,7 @@ ("weather", "Current conditions + today's forecast"), ("air", "VPD, dew point, feels-like"), ("brief", "Send the morning brief now"), + ("deploy", "Pull latest code and restart services"), ("help", "List all commands"), ] @@ -185,6 +191,49 @@ def _air() -> str: return "\n".join(lines) +def _deploy() -> str: + """ + Kick off deploy.sh (git pull, dep sync, service restarts) and reply + immediately β€” the run itself takes tens of seconds and restarts this very + process, so it can't run inline and report back the normal way. + + Launched via `systemd-run` as its own transient unit rather than a plain + subprocess: deploy.sh's job includes `sudo systemctl restart garden-agent`, + and a plain child process would share this service's cgroup and get + killed by that restart before finishing the remaining steps (cron/backup + timers, webhook re-registration, status summary). `--collect` drops the + transient unit once it exits instead of leaving it around as "failed". + deploy.sh sends its own completion/failure message to Telegram directly + (see deploy.sh) since this process may not survive to do it. + + Requires passwordless sudo for `systemd-run` (and, as before, for the + systemctl/tee calls inside deploy.sh) β€” see docs/telegram.md. + """ + try: + subprocess.Popen( + [ + "sudo", "systemd-run", + "--unit=garden-deploy", + "--collect", + "--description=garden-agent deploy (via /deploy)", + "--working-directory", str(_APP_DIR), + "bash", "-c", f"bash deploy.sh >> {_DEPLOY_LOG} 2>&1", + ], + cwd=_APP_DIR, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + log.exception("Failed to launch deploy.sh") + return "Failed to start the deploy β€” check garden-agent logs on the VM." + return ( + "\U0001f680 Deploy started (git pull, restart services). " + "This restarts garden-agent, so the bot will be briefly unreachable. " + "You'll get a message here when it's done." + ) + + def _help() -> str: lines = ["Available commands"] for cmd, desc in _bed_commands() + _STATIC_COMMANDS: @@ -209,6 +258,8 @@ def dispatch(command: str) -> str: from garden.agent.runner import send_daily_brief send_daily_brief(force=True) return "Morning brief sent." + if command == "deploy": + return _deploy() return _help() diff --git a/garden/dashboard/static/js/garden.js b/garden/dashboard/static/js/garden.js index 66894ed..0415f0d 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -2044,7 +2044,10 @@ function renderBedChips(insightBeds) { ════════════════════════════════════════════════════════════════════════════ */ /** How far below band.min still counts as "drying" rather than a hard "dry". - * Fraction of band width, floored so tight bands still get a usable margin. */ + * Fraction of band width, floored so tight bands still get a usable margin. + * Mirrored in Python as derived.near_dry_margin() (garden/derived.py), used + * by the "Low soil moisture" Telegram alert so it agrees with this chart on + * what "Dry" means. Keep these two formulas in sync. */ function _nearDryMargin(band) { const width = Math.max(0, band.max - band.min); return Math.max(2, width * 0.25); diff --git a/garden/derived.py b/garden/derived.py index b91ada8..346c0a9 100644 --- a/garden/derived.py +++ b/garden/derived.py @@ -340,6 +340,17 @@ def _percentile(values: list[float], pct: float) -> float: return ordered[lo] + (ordered[hi] - ordered[lo]) * frac +def near_dry_margin(band: tuple[float, float]) -> float: + """ + How far below band[0] (min) still counts as merely "drying" rather than + truly dry β€” mirrors _nearDryMargin in garden/dashboard/static/js/garden.js + so the Telegram alert and the "Soil moisture vs. band" chart agree on what + "Dry" means for a given bed. Keep these two formulas in sync. + """ + width = max(0.0, band[1] - band[0]) + return max(2.0, width * 0.25) + + def effective_moisture_band( values: list[float], plants: list[str], diff --git a/tests/test_bot.py b/tests/test_bot.py index aa9e3f6..5a83dad 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -85,6 +85,33 @@ def test_dispatch_air_with_readings(monkeypatch): assert "2.38" in reply +# ── dispatch: /deploy ────────────────────────────────────────────────────────── + +def test_dispatch_deploy_launches_systemd_run(monkeypatch): + calls = [] + monkeypatch.setattr(bot.subprocess, "Popen", lambda args, **kw: calls.append((args, kw))) + + reply = bot.dispatch("deploy") + + assert len(calls) == 1 + args, kwargs = calls[0] + assert args[:3] == ["sudo", "systemd-run", "--unit=garden-deploy"] + assert "--collect" in args + assert any("deploy.sh" in a for a in args) + assert kwargs["stdin"] is bot.subprocess.DEVNULL + assert "Deploy started" in reply + + +def test_dispatch_deploy_failure_to_launch_is_reported(monkeypatch): + def _boom(args, **kw): + raise OSError("sudo not found") + + monkeypatch.setattr(bot.subprocess, "Popen", _boom) + + reply = bot.dispatch("deploy") + assert "Failed to start" in reply + + # ── dispatch: /help fallback ─────────────────────────────────────────────────── def test_dispatch_help_lists_bed_and_static_commands(): @@ -105,8 +132,8 @@ def test_command_menu_has_one_entry_per_bed_plus_static(): commands = {m["command"] for m in menu} n_beds = len(cfg.dashboard.get("beds", [])) assert {"bed1", "bed2", "bed3", "bed4"} <= commands - assert {"beds", "weather", "air", "brief", "help"} <= commands - assert len(menu) == n_beds + 5 + assert {"beds", "weather", "air", "brief", "deploy", "help"} <= commands + assert len(menu) == n_beds + 6 # ── handle_update: owner-only guard + parsing ───────────────────────────────── diff --git a/tests/test_rules.py b/tests/test_rules.py index 49da030..33c0265 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -39,8 +39,11 @@ def test_falls_back_to_flat_threshold_without_history(self): threshold = _bed_dry_threshold(BED1_LOOSE, fallback=30.0) # No history and no recognised-crop band mismatch here: falls back to # the crop-derived band (tomato min 50), not the flat fallback, - # because bed_moisture_band still resolves without any samples. - assert threshold == 50.0 + # because bed_moisture_band still resolves without any samples. The + # returned threshold is the band min minus its near-dry margin + # (band (50, 80) -> width 30 -> margin max(2, 30*0.25)=7.5 -> 42.5), + # matching the dashboard's "Dry" cutoff rather than the raw band edge. + assert threshold == 42.5 def test_learns_a_lower_threshold_for_loose_soil(self): series_rows = [{"value": v} for v in _sawtooth(35, 58)] @@ -60,7 +63,7 @@ def test_disabled_learning_uses_crop_band(self): threshold = _bed_dry_threshold(BED1_LOOSE, fallback=30.0) finally: cfg.thresholds["moisture_learning"] = original - assert threshold == 50.0 # crop band min, learning bypassed + assert threshold == 42.5 # crop band min (50) minus near-dry margin (7.5), learning bypassed class TestCheckSoilMoistureLow: