v0.9.0 — One-click self-update (homegrown, no OTM)
Add an admin 'Pull & rebuild' button (Settings → Updates). It POSTs an admin-gated route that signals a privileged 'updater' sidecar (adnanh/webhook + docker socket) over the internal network with a shared secret; the sidecar runs 'git fetch && reset --hard origin/master && docker compose up -d --build app' detached. The UI polls /api/version until the rebuilt app returns on the new version, then reloads. Secret matching uses webhook -template + getenv so UPDATER_SECRET stays out of the repo (host .env only). No request data reaches the shell — the command is fixed in updater/update.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,9 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
GITEA_URL="http://192.168.0.5:3022"
|
||||
GITEA_TOKEN=""
|
||||
GITEA_REPO="bonna61/Moonbase"
|
||||
|
||||
# Self-update — the admin "Pull & rebuild" button signals the updater sidecar.
|
||||
# UPDATER_SECRET is shared between the app and the updater (set in the host .env,
|
||||
# never committed). UPDATER_URL points at the sidecar on the internal network.
|
||||
UPDATER_URL="http://updater:9000/hooks/moonbase-update"
|
||||
UPDATER_SECRET=""
|
||||
|
||||
7
Dockerfile.updater
Normal file
7
Dockerfile.updater
Normal file
@@ -0,0 +1,7 @@
|
||||
# Privileged self-update sidecar. Holds the docker socket and, on an
|
||||
# authenticated webhook trigger, runs `git pull && docker compose up -d --build app`
|
||||
# against the moonbase stack. It only ever runs the fixed updater/update.sh —
|
||||
# no request data reaches the shell. See docker-compose.yml + updater/hooks.json.
|
||||
FROM docker:27-cli
|
||||
RUN apk add --no-cache git docker-cli-compose webhook
|
||||
ENTRYPOINT ["webhook"]
|
||||
@@ -29,12 +29,41 @@ services:
|
||||
GITEA_URL: http://192.168.0.5:3022
|
||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
GITEA_REPO: bonna61/Moonbase
|
||||
# Self-update — admin "Pull & rebuild" signals the updater sidecar.
|
||||
UPDATER_URL: http://updater:9000/hooks/moonbase-update
|
||||
UPDATER_SECRET: ${UPDATER_SECRET}
|
||||
volumes:
|
||||
# Persist suggestion screenshot uploads across rebuilds.
|
||||
- /mnt/user/appdata/moonbase/uploads:/app/public/uploads
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# Privileged self-update sidecar: holds the docker socket and rebuilds `app`
|
||||
# on an authenticated webhook trigger. Only runs the fixed updater/update.sh.
|
||||
updater:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.updater
|
||||
image: moonbase-updater:latest
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- -hooks
|
||||
- /appdata/updater/hooks.json
|
||||
- -template
|
||||
- -hotreload
|
||||
- -ip
|
||||
- 0.0.0.0
|
||||
- -port
|
||||
- "9000"
|
||||
- -verbose
|
||||
environment:
|
||||
UPDATER_SECRET: ${UPDATER_SECRET}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /mnt/user/appdata/moonbase:/appdata
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
14
src/app/(dashboard)/settings/updates/page.tsx
Normal file
14
src/app/(dashboard)/settings/updates/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession, isAdmin } from "@/lib/auth";
|
||||
import { SelfUpdatePanel } from "@/components/settings/self-update-panel";
|
||||
|
||||
export const metadata: Metadata = { title: "Updates" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UpdatesPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
if (!isAdmin(session.user.role)) redirect("/dashboard");
|
||||
return <SelfUpdatePanel />;
|
||||
}
|
||||
42
src/app/api/account/self-update/route.ts
Normal file
42
src/app/api/account/self-update/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession, isAdmin } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Admin-only trigger for the homegrown self-updater. Signals the privileged
|
||||
// `updater` sidecar (which holds the docker socket) over the internal network
|
||||
// with a shared secret; the sidecar runs `git pull && docker compose up -d
|
||||
// --build app` detached. No user input reaches the shell — the command is fixed
|
||||
// in updater/update.sh. The browser then polls /api/version for the new version.
|
||||
export async function POST() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!isAdmin(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const url = process.env.UPDATER_URL;
|
||||
const secret = process.env.UPDATER_SECRET;
|
||||
if (!url || !secret) {
|
||||
return NextResponse.json({ error: "not_configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "X-Update-Token": secret },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `updater responded ${res.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ started: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "updater unreachable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb } from "lucide-react";
|
||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
@@ -17,6 +17,7 @@ const navItems = [
|
||||
const settingsItems = [
|
||||
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
|
||||
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
|
||||
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
151
src/components/settings/self-update-panel.tsx
Normal file
151
src/components/settings/self-update-panel.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, CheckCircle2, AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
type Status = { version: string; latest: string | null; updateAvailable: boolean };
|
||||
type Phase = "idle" | "starting" | "rebuilding" | "done" | "error";
|
||||
|
||||
export function SelfUpdatePanel() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const startVersion = useRef<string | null>(null);
|
||||
const poll = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
async function loadStatus(): Promise<Status | null> {
|
||||
try {
|
||||
const r = await fetch("/api/version", { cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
const d = (await r.json()) as Status;
|
||||
setStatus(d);
|
||||
return d;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
return () => {
|
||||
if (poll.current) clearInterval(poll.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function triggerUpdate() {
|
||||
setPhase("starting");
|
||||
setMessage("Starting update…");
|
||||
startVersion.current = status?.version ?? null;
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/account/self-update", { method: "POST" });
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
setPhase("error");
|
||||
setMessage(
|
||||
j.error === "not_configured"
|
||||
? "The updater isn't set up on this server yet."
|
||||
: `Couldn't start the update (${r.status}).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setPhase("error");
|
||||
setMessage("Couldn't reach the server to start the update.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("rebuilding");
|
||||
setMessage(
|
||||
"Pulling the latest version and rebuilding. This can take a couple of minutes — don't close this page."
|
||||
);
|
||||
|
||||
let tries = 0;
|
||||
poll.current = setInterval(async () => {
|
||||
tries++;
|
||||
const d = await loadStatus();
|
||||
if (d && startVersion.current && d.version !== startVersion.current) {
|
||||
if (poll.current) clearInterval(poll.current);
|
||||
setPhase("done");
|
||||
setMessage(`Updated to v${d.version}. Reloading…`);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else if (tries > 90) {
|
||||
// ~6 minutes with no version change
|
||||
if (poll.current) clearInterval(poll.current);
|
||||
setPhase("error");
|
||||
setMessage(
|
||||
"The update is taking longer than expected. Check the server, then reload this page."
|
||||
);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
const busy = phase === "starting" || phase === "rebuilding";
|
||||
const updateAvailable = !!status?.updateAvailable && !!status.latest;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-display font-semibold tracking-tight">Updates</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pull the latest version of Moon Base and rebuild the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
Current version
|
||||
<span className="tabular-nums font-normal text-muted-foreground">
|
||||
v{status?.version ?? "…"}
|
||||
</span>
|
||||
{updateAvailable ? (
|
||||
<Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
Update available → v{status!.latest}
|
||||
</Badge>
|
||||
) : status ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
|
||||
Up to date
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{phase !== "idle" && (
|
||||
<div
|
||||
className={
|
||||
"flex items-start gap-2 rounded-md px-3 py-2 text-sm " +
|
||||
(phase === "error"
|
||||
? "bg-red-50 text-red-800"
|
||||
: phase === "done"
|
||||
? "bg-emerald-50 text-emerald-800"
|
||||
: "bg-amber-50 text-amber-800")
|
||||
}
|
||||
>
|
||||
{phase === "error" ? (
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : phase === "done" ? (
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 mt-0.5 shrink-0 animate-spin" />
|
||||
)}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={triggerUpdate} disabled={busy} className="gap-2">
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{updateAvailable ? `Update to v${status!.latest}` : "Pull & rebuild"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.9.0",
|
||||
date: "2026-06-15",
|
||||
changes: [
|
||||
"One-click updates — admins get a new Updates page under Settings with a \"Pull & rebuild\" button. When a new version is available, click it and the app updates itself and reloads. No more waiting on Tony.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
date: "2026-06-15",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||
export const APP_VERSION = "0.8.0";
|
||||
export const APP_VERSION = "0.9.0";
|
||||
|
||||
15
updater/hooks.json
Normal file
15
updater/hooks.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"id": "moonbase-update",
|
||||
"execute-command": "/appdata/updater/update.sh",
|
||||
"command-working-directory": "/appdata",
|
||||
"response-message": "update started",
|
||||
"trigger-rule": {
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": "{{ getenv \"UPDATER_SECRET\" }}",
|
||||
"parameter": { "source": "header", "name": "X-Update-Token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
19
updater/update.sh
Executable file
19
updater/update.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Triggered by the webhook sidecar when an admin clicks "Pull & rebuild".
|
||||
# Runs the rebuild DETACHED so the webhook responds immediately — otherwise the
|
||||
# app container (which the admin's browser is talking to) would be recreated
|
||||
# mid-request. The app UI then polls /api/version until the new version answers.
|
||||
#
|
||||
# Only `app` is rebuilt: postgres and this updater are left running untouched.
|
||||
set -e
|
||||
|
||||
nohup sh -c '
|
||||
cd /appdata
|
||||
echo "=== update $(date) ==="
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
COMPOSE_PROJECT_NAME=moonbase docker compose up -d --build app
|
||||
echo "=== done $(date) ==="
|
||||
' >> /appdata/update.log 2>&1 &
|
||||
|
||||
echo "update started"
|
||||
Reference in New Issue
Block a user