diff --git a/README.md b/README.md index 9b0d033..195729d 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,24 @@ The installer creates: - Environment file: `/etc/synapsis.env` - Embedded Turso database: `/var/lib/synapsis/synapsis.db` - Service: `synapsis.service` +- Mandatory update timer: `synapsis-update.timer` + +Every node checks `origin/main` about once per minute. When a new commit is available, Synapsis fast-forwards the checkout, replaces the single `backups/latest` database snapshot, installs dependencies, runs migrations, builds, and restarts automatically. The currently deployed commit is shown in the Network Info card and exposed by `/api/version`. + +For a node installed before automatic updates existed, bootstrap the timer once with: + +```bash +sudo -u synapsis git -C /opt/synapsis pull --ff-only +sudo /opt/synapsis/deploy/update.sh +``` Useful commands: ```bash sudo systemctl status synapsis +sudo systemctl status synapsis-update.timer sudo journalctl -u synapsis -f +sudo journalctl -u synapsis-update -f sudo /opt/synapsis/deploy/update.sh sudo /opt/synapsis/deploy/uninstall.sh ``` diff --git a/deploy/install.sh b/deploy/install.sh index a025271..c38484a 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -59,8 +59,12 @@ runuser -u synapsis -- npm --prefix "$APP_DIR" run db:migrate runuser -u synapsis -- npm --prefix "$APP_DIR" run build install -m 0644 "$APP_DIR/deploy/synapsis.service" /etc/systemd/system/synapsis.service +install -m 0644 "$APP_DIR/deploy/synapsis-update.service" /etc/systemd/system/synapsis-update.service +install -m 0644 "$APP_DIR/deploy/synapsis-update.timer" /etc/systemd/system/synapsis-update.timer +runuser -u synapsis -- git -C "$APP_DIR" rev-parse HEAD | install -m 0644 -o synapsis -g synapsis /dev/stdin "$DATA_DIR/deployed-commit" systemctl daemon-reload systemctl enable --now synapsis +systemctl enable --now synapsis-update.timer echo "Synapsis is listening on http://127.0.0.1:${PORT}" echo "Edit $ENV_FILE, run $APP_DIR/deploy/update.sh, then configure your own reverse proxy." diff --git a/deploy/synapsis-update.service b/deploy/synapsis-update.service new file mode 100644 index 0000000..f31a155 --- /dev/null +++ b/deploy/synapsis-update.service @@ -0,0 +1,11 @@ +[Unit] +Description=Update Synapsis to the latest main commit +After=network-online.target +Wants=network-online.target +ConditionPathExists=/opt/synapsis/.git + +[Service] +Type=oneshot +ExecStart=/opt/synapsis/deploy/update.sh +TimeoutStartSec=30min +UMask=0027 diff --git a/deploy/synapsis-update.timer b/deploy/synapsis-update.timer new file mode 100644 index 0000000..a857697 --- /dev/null +++ b/deploy/synapsis-update.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Keep Synapsis on the latest main commit + +[Timer] +OnBootSec=1min +OnUnitInactiveSec=1min +RandomizedDelaySec=15s +AccuracySec=5s +Persistent=true +Unit=synapsis-update.service + +[Install] +WantedBy=timers.target diff --git a/deploy/uninstall.sh b/deploy/uninstall.sh index b95b1f6..97f3db5 100755 --- a/deploy/uninstall.sh +++ b/deploy/uninstall.sh @@ -10,8 +10,12 @@ if [[ ${EUID} -ne 0 ]]; then exit 1 fi +systemctl disable --now synapsis-update.timer 2>/dev/null || true +systemctl stop synapsis-update.service 2>/dev/null || true systemctl disable --now synapsis 2>/dev/null || true -rm -f /etc/systemd/system/synapsis.service +rm -f /etc/systemd/system/synapsis.service \ + /etc/systemd/system/synapsis-update.service \ + /etc/systemd/system/synapsis-update.timer systemctl daemon-reload rm -rf "$APP_DIR" diff --git a/deploy/update.sh b/deploy/update.sh index 924976f..bcd0201 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -4,6 +4,8 @@ set -euo pipefail APP_DIR="${APP_DIR:-/opt/synapsis}" DATA_DIR="${DATA_DIR:-/var/lib/synapsis}" ENV_FILE="${ENV_FILE:-/etc/synapsis.env}" +BRANCH="${BRANCH:-main}" +DEPLOYED_COMMIT_FILE="$DATA_DIR/deployed-commit" if [[ ${EUID} -ne 0 ]]; then echo "Run this updater as root." >&2 @@ -17,27 +19,83 @@ set -a source "$ENV_FILE" set +a +install_update_units() { + units_changed=0 + for unit in synapsis.service synapsis-update.service synapsis-update.timer; do + if ! cmp -s "$APP_DIR/deploy/$unit" "/etc/systemd/system/$unit"; then + install -m 0644 "$APP_DIR/deploy/$unit" "/etc/systemd/system/$unit" + units_changed=1 + fi + done + + if [[ "$units_changed" == "1" ]]; then + systemctl daemon-reload + fi + if ! systemctl is-enabled --quiet synapsis-update.timer; then + systemctl enable synapsis-update.timer + fi + if ! systemctl is-active --quiet synapsis-update.timer; then + systemctl start synapsis-update.timer + fi +} + +if [[ "${SYNAPSIS_APPLY_UPDATE:-0}" != "1" ]]; then + runuser -u synapsis -- git -C "$APP_DIR" fetch --prune origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + + current_commit="$(runuser -u synapsis -- git -C "$APP_DIR" rev-parse HEAD)" + target_commit="$(runuser -u synapsis -- git -C "$APP_DIR" rev-parse "origin/$BRANCH")" + deployed_commit="" + if [[ -f "$DEPLOYED_COMMIT_FILE" ]]; then + deployed_commit="$(tr -d '[:space:]' < "$DEPLOYED_COMMIT_FILE")" + fi + + if [[ -n "$deployed_commit" && "$deployed_commit" == "$target_commit" ]]; then + install_update_units + echo "Synapsis is already current at ${target_commit:0:7}." + exit 0 + fi + + if [[ "$current_commit" != "$target_commit" ]]; then + if ! runuser -u synapsis -- git -C "$APP_DIR" merge-base --is-ancestor "$current_commit" "$target_commit"; then + echo "Cannot auto-update: local checkout has diverged from origin/$BRANCH." >&2 + exit 1 + fi + runuser -u synapsis -- git -C "$APP_DIR" merge --ff-only "origin/$BRANCH" + fi + + exec env SYNAPSIS_APPLY_UPDATE=1 APP_DIR="$APP_DIR" DATA_DIR="$DATA_DIR" ENV_FILE="$ENV_FILE" BRANCH="$BRANCH" bash "$APP_DIR/deploy/update.sh" +fi + systemctl stop synapsis trap 'systemctl start synapsis' EXIT if [[ -f "${DATABASE_PATH:-$DATA_DIR/synapsis.db}" ]]; then database_file="${DATABASE_PATH:-$DATA_DIR/synapsis.db}" - backup_dir="$DATA_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)" - install -d -o synapsis -g synapsis -m 0750 "$backup_dir" - cp "$database_file" "$backup_dir/synapsis.db" + backup_root="$DATA_DIR/backups" + backup_staging="$backup_root/.latest.$$" + backup_dir="$backup_root/latest" + install -d -o synapsis -g synapsis -m 0750 "$backup_root" + rm -rf "$backup_staging" + install -d -o synapsis -g synapsis -m 0750 "$backup_staging" + cp "$database_file" "$backup_staging/synapsis.db" for suffix in -wal -shm; do if [[ -f "${database_file}${suffix}" ]]; then - cp "${database_file}${suffix}" "$backup_dir/synapsis.db${suffix}" + cp "${database_file}${suffix}" "$backup_staging/synapsis.db${suffix}" fi done - chown -R synapsis:synapsis "$backup_dir" + chown -R synapsis:synapsis "$backup_staging" + rm -rf "$backup_dir" + mv "$backup_staging" "$backup_dir" + find "$backup_root" -mindepth 1 -maxdepth 1 ! -name latest -exec rm -rf {} + fi -runuser -u synapsis -- git -C "$APP_DIR" pull --ff-only runuser -u synapsis -- npm --prefix "$APP_DIR" ci runuser -u synapsis -- npm --prefix "$APP_DIR" run db:migrate runuser -u synapsis -- npm --prefix "$APP_DIR" run build +deployed_commit="$(runuser -u synapsis -- git -C "$APP_DIR" rev-parse HEAD)" +printf '%s\n' "$deployed_commit" | install -m 0644 -o synapsis -g synapsis /dev/stdin "$DEPLOYED_COMMIT_FILE" +install_update_units systemctl start synapsis trap - EXIT -echo "Synapsis updated successfully." +echo "Synapsis updated successfully to ${deployed_commit:0:7}." diff --git a/next.config.ts b/next.config.ts index 7c8ec18..95f8d79 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,25 @@ import type { NextConfig } from "next"; +import { execFileSync } from "node:child_process"; + +function getBuildCommit(): string { + if (process.env.APP_COMMIT) return process.env.APP_COMMIT; + + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: process.cwd(), + encoding: 'utf8', + }).trim(); + } catch { + return 'unknown'; + } +} const nextConfig: NextConfig = { + env: { + APP_COMMIT: getBuildCommit(), + APP_BUILD_DATE: process.env.APP_BUILD_DATE || new Date().toISOString(), + }, + // Turso ships a platform-native Node module and must remain a runtime dependency. serverExternalPackages: ['@tursodatabase/database'], diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index 62d2658..b6299ff 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -31,6 +31,7 @@ export function RightSidebar() { }); const [version, setVersion] = useState<{ version: string; + commit: string | null; buildDate: string | null; } | null>(null); @@ -79,7 +80,7 @@ export function RightSidebar() { fetch('/api/version') .then(res => res.json()) .then(data => setVersion(data)) - .catch(() => setVersion({ version: 'unknown', buildDate: null })); + .catch(() => setVersion({ version: 'unknown', commit: null, buildDate: null })); return () => window.removeEventListener('synapsis:node-updated', handleNodeUpdated); }, []); @@ -152,8 +153,12 @@ export function RightSidebar() {

Network Info

Running{' '} - Synapsis - {version?.version ? ` ${version.version}` : ''} + Synapsis + {version?.commit + ? ` ${version.commit.slice(0, 7)}` + : version?.version + ? ` ${version.version}` + : ''}

{nodeInfo.admins.length > 0 && ( diff --git a/src/lib/swarm/discovery.ts b/src/lib/swarm/discovery.ts index d826e4d..9146edc 100644 --- a/src/lib/swarm/discovery.ts +++ b/src/lib/swarm/discovery.ts @@ -7,6 +7,7 @@ import { db, nodes, users, posts } from '@/db'; import { eq, sql } from 'drizzle-orm'; import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types'; +import { getCurrentBuildInfo } from '@/lib/version'; import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry'; import { getCanonicalSwarmSeedDomain, @@ -22,6 +23,7 @@ const PUBLIC_SWARM_DOMAIN_ERROR = 'Public swarm participation requires a real IC */ export async function buildAnnouncement(): Promise { const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; + const buildInfo = getCurrentBuildInfo(); let name = 'Synapsis Node'; let description: string | undefined; @@ -61,7 +63,7 @@ export async function buildAnnouncement(): Promise { description, logoUrl, publicKey, - softwareVersion: '0.1.0', // TODO: Get from package.json + softwareVersion: buildInfo.commit || buildInfo.version, userCount, postCount, capabilities,