Skip to content

Commit 93b4f4f

Browse files
authored
feat(update): add --dry-run flag showing version, images, and .env diff without applying (#411)
* feat(update): add --dry-run flag showing version, images, and .env diff without applying * fix: dream update flags, dry-run images_shown, updates typing
1 parent 5f76738 commit 93b4f4f

2 files changed

Lines changed: 226 additions & 48 deletions

File tree

dream-server/dream-cli

Lines changed: 134 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -572,83 +572,169 @@ cmd_start() {
572572
fi
573573
}
574574

575-
cmd_repair() {
575+
cmd_dry_run() {
576576
check_install
577577
cd "$INSTALL_DIR"
578-
sr_load
579578

580-
local service="${1:-}"
581-
if [[ -z "$service" ]]; then
582-
error "Usage: dream repair <service> (e.g. dream repair perplexica)"
579+
echo -e "${BLUE}━━━ Dream Update — Dry Run ━━━${NC}"
580+
echo -e "${CYAN}Preview only. No changes will be applied.${NC}"
581+
echo ""
582+
583+
# ── version ──────────────────────────────────────────────────────────────
584+
local cur_ver="0.0.0"
585+
if [[ -f "$INSTALL_DIR/.env" ]]; then
586+
local _v
587+
_v=$(grep '^DREAM_VERSION=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
588+
[[ -n "$_v" ]] && cur_ver="$_v"
589+
fi
590+
if [[ "$cur_ver" == "0.0.0" && -f "$INSTALL_DIR/.version" ]]; then
591+
local _vf
592+
_vf=$(jq -r '.version // empty' "$INSTALL_DIR/.version" 2>/dev/null)
593+
[[ -n "$_vf" ]] && cur_ver="$_vf"
583594
fi
584595

585-
service=$(resolve_service "$service")
586-
local container="${SERVICE_CONTAINERS[$service]:-dream-$service}"
596+
# Try dashboard API first; fall back to direct GitHub query.
597+
local api_json=""
598+
local dashboard_port="${DASHBOARD_PORT:-3002}"
599+
local api_key=""
600+
api_key=$(grep '^DASHBOARD_API_KEY=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
601+
if [[ -n "$api_key" ]]; then
602+
api_json=$(curl -sf --max-time 5 \
603+
-H "X-API-Key: ${api_key}" \
604+
"http://localhost:${dashboard_port}/api/update/dry-run" 2>/dev/null || true)
605+
fi
587606

588-
warn "This will destroy all data for $service and recreate it from scratch."
589-
read -p " Continue? [y/N] " -n 1 -r
590-
echo ""
591-
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
592-
log "Repair cancelled."
593-
return 0
607+
local latest_ver="" changelog_url="" update_status=""
608+
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.current_version' >/dev/null 2>&1; then
609+
latest_ver=$(echo "$api_json" | jq -r '.latest_version // empty')
610+
changelog_url=$(echo "$api_json" | jq -r '.changelog_url // empty')
611+
local api_available
612+
api_available=$(echo "$api_json" | jq -r '.update_available // false')
613+
[[ "$api_available" == "true" ]] && update_status="update available" || update_status="up to date"
614+
else
615+
# Direct GitHub fallback
616+
local gh_resp
617+
gh_resp=$(curl -sf --max-time 8 \
618+
"https://api.github.com/repos/Light-Heart-Labs/DreamServer/releases/latest" 2>/dev/null || true)
619+
latest_ver=$(echo "$gh_resp" | jq -r '.tag_name // empty' 2>/dev/null | sed 's/^v//')
620+
changelog_url=$(echo "$gh_resp" | jq -r '.html_url // empty' 2>/dev/null)
621+
if [[ -n "$latest_ver" ]]; then
622+
if _semver_lt "$cur_ver" "$latest_ver"; then
623+
update_status="update available"
624+
else
625+
update_status="up to date"
626+
fi
627+
fi
594628
fi
595629

630+
echo -e "${CYAN}Version:${NC}"
631+
echo " Installed : v${cur_ver}"
632+
if [[ -n "$latest_ver" ]]; then
633+
echo " Available : v${latest_ver}"
634+
if [[ "$update_status" == "update available" ]]; then
635+
echo -e " Status : ${YELLOW}${update_status}${NC}"
636+
else
637+
echo -e " Status : ${GREEN}${update_status}${NC}"
638+
fi
639+
[[ -n "$changelog_url" ]] && echo " Changelog : ${changelog_url}"
640+
else
641+
echo " Available : (could not reach GitHub)"
642+
fi
643+
echo ""
644+
645+
# ── image tags ────────────────────────────────────────────────────────────
646+
echo -e "${CYAN}Configured image tags (would be pulled):${NC}"
596647
local flags_str
597648
flags_str=$(get_compose_flags)
598649
local -a flags
599650
read -ra flags <<< "$flags_str"
600651

601-
log "Repairing $service..."
602-
603-
# Stop and remove container
604-
log "Stopping $service..."
605-
docker stop "$container" 2>/dev/null || warn "$container not running"
606-
docker rm "$container" 2>/dev/null || warn "$container not found"
607-
608-
# Find and remove service volumes
609-
local volumes
610-
volumes=$(docker compose "${flags[@]}" config --volumes 2>/dev/null | grep -iE "^${service}([-_]|$)" || true)
611-
local project
612-
project=$(basename "$INSTALL_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]-')
613-
for vol in $volumes; do
614-
local full_vol="${project}_${vol}"
615-
if docker volume inspect "$full_vol" >/dev/null 2>&1; then
616-
log "Removing volume $full_vol..."
617-
docker volume rm "$full_vol" || warn "Could not remove volume $full_vol"
618-
fi
652+
# Prefer API response; fall back to parsing compose files directly.
653+
# Use process substitution so flags like images_shown update in this shell (not a pipe subshell).
654+
local images_shown=false
655+
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.images | length > 0' >/dev/null 2>&1; then
656+
local _img_line _any_api=false
657+
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
658+
[[ -z "$_img_line" ]] && continue
659+
echo " ${_img_line}"
660+
_any_api=true
661+
done < <(echo "$api_json" | jq -r '.images[]' | sort -u)
662+
[[ "$_any_api" == "true" ]] && images_shown=true
663+
fi
664+
if [[ "$images_shown" == "false" ]]; then
665+
local _img_line _any_compose=false
666+
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
667+
[[ -z "$_img_line" ]] && continue
668+
echo " ${_img_line}"
669+
_any_compose=true
670+
done < <(docker compose "${flags[@]}" config 2>/dev/null \
671+
| grep -E '^\s+image:' | sed 's/.*image:\s*//' | sort -u)
672+
[[ "$_any_compose" == "true" ]] && images_shown=true
673+
fi
674+
[[ "$images_shown" == "false" ]] && echo " (could not resolve compose config)"
675+
676+
echo ""
677+
echo -e "${CYAN}Currently running image digests:${NC}"
678+
docker compose "${flags[@]}" images 2>/dev/null \
679+
| awk 'NR>1 {printf " %-30s %s\n", $1, $4}' \
680+
|| echo " (services not running)"
681+
echo ""
682+
683+
# ── model / GGUF ─────────────────────────────────────────────────────────
684+
echo -e "${CYAN}Model configuration (.env):${NC}"
685+
local -a model_keys=(TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)
686+
local key
687+
for key in "${model_keys[@]}"; do
688+
local val
689+
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
690+
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
619691
done
692+
echo ""
693+
694+
# ── .env keys the update path reads/writes ────────────────────────────────
695+
echo -e "${CYAN}.env keys the update path will read or write:${NC}"
696+
local -a update_keys=(DREAM_VERSION TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)
620697

621-
# Recreate container
622-
log "Recreating $service..."
623-
docker compose "${flags[@]}" up -d "$service"
624-
625-
# Run service-specific repair script if one exists
626-
local repair_script="$INSTALL_DIR/scripts/repair/repair-${service}.sh"
627-
if [[ -x "$repair_script" ]]; then
628-
local port="${SERVICE_PORTS[$service]:-}"
629-
local model="${LLM_MODEL:-qwen3-14b}"
630-
log "Running config repair for $service..."
631-
bash "$repair_script" "http://localhost:${port}" "$model" && \
632-
success "$service repaired and configured" || \
633-
warn "$service started but config seed failed — may need manual setup"
698+
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.env_keys' >/dev/null 2>&1; then
699+
echo "$api_json" | jq -r '.env_keys | to_entries[] | " \(.key): \(.value)"' 2>/dev/null \
700+
|| true
634701
else
635-
success "$service repaired"
702+
for key in "${update_keys[@]}"; do
703+
local val
704+
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
705+
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
706+
done
636707
fi
708+
echo ""
709+
710+
# ── summary ───────────────────────────────────────────────────────────────
711+
echo -e "${CYAN}To apply this update:${NC} dream update"
712+
echo ""
713+
echo -e "${YELLOW}Dry run complete. Nothing was changed.${NC}"
637714
}
638715

639716
cmd_update() {
640717
check_install
641718
cd "$INSTALL_DIR"
642719

720+
# Parse flags
721+
local dry_run="false"
643722
local force_flag="false"
644-
local args=()
645723
while [[ $# -gt 0 ]]; do
646724
case "$1" in
725+
--dry-run|-n) dry_run="true"; shift ;;
647726
--force|-f) force_flag="true"; shift ;;
648-
*) args+=("$1"); shift ;;
727+
--*) error "Unknown option: $1" ;;
728+
-*) error "Unknown option: $1" ;;
729+
*) error "Unexpected argument: $1" ;;
649730
esac
650731
done
651732

733+
if [[ "$dry_run" == "true" ]]; then
734+
cmd_dry_run
735+
return 0
736+
fi
737+
652738
local flags_str
653739
flags_str=$(get_compose_flags)
654740
local -a flags
@@ -1910,6 +1996,7 @@ ${CYAN}Examples:${NC}
19101996
# Export preset for sharing
19111997
dream preset import shared.tar.gz
19121998
# Import preset from file
1999+
dream update --dry-run # Preview changes without applying
19132000
dream backup # Create a backup
19142001
dream backup -c # Create compressed backup
19152002
dream backup -l # List all backups

dream-server/extensions/services/dashboard-api/routers/updates.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from datetime import datetime, timezone
77
from pathlib import Path
8+
from typing import Optional
89

910
import httpx
1011
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@@ -81,7 +82,97 @@ async def get_release_manifest():
8182
}
8283

8384

84-
_VALID_ACTIONS = {"check", "backup", "update"}
85+
_UPDATE_ENV_KEYS = {
86+
"DREAM_VERSION", "TIER", "LLM_MODEL", "GGUF_FILE",
87+
"CTX_SIZE", "GPU_BACKEND", "N_GPU_LAYERS",
88+
}
89+
90+
91+
@router.get("/api/update/dry-run", dependencies=[Depends(verify_api_key)])
92+
async def get_update_dry_run():
93+
"""Preview what a dream update would change without applying anything.
94+
95+
Returns version comparison, configured image tags, and the .env keys
96+
that the update process reads or writes. No containers are started,
97+
stopped, or re-created.
98+
"""
99+
import urllib.request
100+
import urllib.error
101+
102+
install_path = Path(INSTALL_DIR)
103+
104+
# ── current version ───────────────────────────────────────────────────────
105+
current = "0.0.0"
106+
env_file = install_path / ".env"
107+
version_file = install_path / ".version"
108+
109+
if env_file.exists():
110+
for line in env_file.read_text().splitlines():
111+
if line.startswith("DREAM_VERSION="):
112+
current = line.split("=", 1)[1].strip()
113+
break
114+
if current == "0.0.0" and version_file.exists():
115+
try:
116+
raw = version_file.read_text().strip()
117+
parsed = json.loads(raw) if raw.startswith("{") else None
118+
current = (parsed or {}).get("version", raw) or raw or "0.0.0"
119+
except (json.JSONDecodeError, OSError):
120+
pass
121+
122+
# ── latest version from GitHub ────────────────────────────────────────────
123+
latest: Optional[str] = None
124+
changelog_url: Optional[str] = None
125+
update_available = False
126+
127+
try:
128+
req = urllib.request.Request(
129+
"https://api.github.com/repos/Light-Heart-Labs/DreamServer/releases/latest",
130+
headers={"Accept": "application/vnd.github.v3+json"},
131+
)
132+
with urllib.request.urlopen(req, timeout=8) as resp:
133+
data = json.loads(resp.read())
134+
latest = data.get("tag_name", "").lstrip("v") or None
135+
changelog_url = data.get("html_url") or None
136+
if latest:
137+
def _parts(v: str) -> list[int]:
138+
return ([int(x) for x in v.split(".") if x.isdigit()][:3] + [0, 0, 0])[:3]
139+
update_available = _parts(latest) > _parts(current)
140+
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError, ValueError):
141+
pass
142+
143+
# ── configured image tags from compose files ──────────────────────────────
144+
images: list[str] = []
145+
for compose_file in sorted(install_path.glob("docker-compose*.yml")):
146+
try:
147+
for line in compose_file.read_text().splitlines():
148+
stripped = line.strip()
149+
if stripped.startswith("image:"):
150+
tag = stripped.split(":", 1)[1].strip()
151+
if tag and tag not in images:
152+
images.append(tag)
153+
except OSError:
154+
pass
155+
156+
# ── .env keys relevant to the update path ────────────────────────────────
157+
env_snapshot: dict[str, str] = {}
158+
if env_file.exists():
159+
for line in env_file.read_text().splitlines():
160+
line = line.strip()
161+
if not line or line.startswith("#") or "=" not in line:
162+
continue
163+
key, _, val = line.partition("=")
164+
if key in _UPDATE_ENV_KEYS:
165+
env_snapshot[key] = val
166+
167+
return {
168+
"dry_run": True,
169+
"current_version": current,
170+
"latest_version": latest,
171+
"update_available": update_available,
172+
"changelog_url": changelog_url,
173+
"images": images,
174+
"env_keys": env_snapshot,
175+
}
85176

86177

87178
@router.post("/api/update")

0 commit comments

Comments
 (0)