diff --git a/core/modules/ConfigStore/schema/restarter.ts b/core/modules/ConfigStore/schema/restarter.ts index 26a8ccbee..459a81cea 100644 --- a/core/modules/ConfigStore/schema/restarter.ts +++ b/core/modules/ConfigStore/schema/restarter.ts @@ -31,9 +31,33 @@ const resourceStartingTolerance = typeDefinedConfig({ fixer: SYM_FIXER_DEFAULT, }); +const updateFileEnabled = typeDefinedConfig({ + name: 'Auto-Restart on Update File', + default: false, + validator: z.boolean(), + fixer: SYM_FIXER_DEFAULT, +}); + +const updateFileName = typeDefinedConfig({ + name: 'Update File Name', + default: '.update', + validator: z.string().trim().min(1).max(255), + fixer: SYM_FIXER_DEFAULT, +}); + +const updateFileDelay = typeDefinedConfig({ + name: 'Update File Restart Delay', + default: 2, //minutes + validator: z.number().int().min(1).max(1439), + fixer: SYM_FIXER_DEFAULT, +}); + export default { schedule, bootGracePeriod, resourceStartingTolerance, + updateFileEnabled, + updateFileName, + updateFileDelay, } as const; diff --git a/core/modules/FxScheduler.ts b/core/modules/FxScheduler.ts index 45a9395b6..2aebfc3dd 100644 --- a/core/modules/FxScheduler.ts +++ b/core/modules/FxScheduler.ts @@ -1,4 +1,6 @@ const modulename = 'FxScheduler'; +import path from 'node:path'; +import fsp from 'node:fs/promises'; import { parseSchedule } from '@lib/misc'; import consoleFactory from '@lib/console'; import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; @@ -6,10 +8,15 @@ import type { UpdateConfigKeySet } from './ConfigStore/utils'; const console = consoleFactory(modulename); +//Consts +const UPDATE_FILE_REASON_MAX_LENGTH = 150; + + //Types type RestartInfo = { string: string; minuteFloorTs: number; + reason?: string; } type ParsedTime = { string: string; @@ -53,6 +60,7 @@ export default class FxScheduler { private nextTempSchedule: RestartInfo | false = false; private calculatedNextRestartMinuteFloorTs: number | false = false; private nextSkip: number | false = false; + private isCheckingUpdateFile = false; constructor() { //Initial check to update status @@ -60,9 +68,10 @@ export default class FxScheduler { this.checkSchedule(); }); - //Cron Function + //Cron Function setInterval(() => { this.checkSchedule(); + this.checkUpdateFile(); txCore.webServer.webSocket.pushRefresh('status'); }, 60 * 1000); } @@ -174,8 +183,9 @@ export default class FxScheduler { /** * Sets this.nextTempSchedule. * The value MUST be before the next setting scheduled time. + * An optional reason can be provided to be shown in the logs and restart message. */ - setNextTempSchedule(timeString: string) { + setNextTempSchedule(timeString: string, reason?: string) { //Process input if (typeof timeString !== 'string') throw new Error('expected string'); const thisMinuteTs = new Date().setSeconds(0, 0); @@ -218,6 +228,7 @@ export default class FxScheduler { this.nextTempSchedule = { string: scheduledString, minuteFloorTs: scheduledMinuteFloorTs, + reason: typeof reason === 'string' && reason.length ? reason : undefined, }; //This is needed to refresh this.calculatedNextRestartMinuteFloorTs @@ -228,6 +239,68 @@ export default class FxScheduler { } + /** + * Checks the server data folder for an "update file" (eg. `.update`) and, if found, + * deletes it and schedules a temporary restart. + * This is meant to help servers that use CI/CD pipelines to deploy updates: the pipeline + * just needs to drop the configured file in the server data folder. + * The file content (if any) is used as the restart reason/message. + */ + async checkUpdateFile() { + //Check if feature is enabled and not already running + if (!txConfig.restarter.updateFileEnabled) return; + if (this.isCheckingUpdateFile) return; + + //Only act when the server is actually running, otherwise leave the file + //to be picked up once the server is up (and a restart makes sense) + if (txCore.fxRunner.isIdle || !txCore.fxRunner.child?.isAlive) return; + + //Resolve the file path (basename to prevent path traversal via config) + const dataPath = txCore.fxRunner.serverPaths?.dataPath; + if (!dataPath) return; + const updateFilePath = path.join(dataPath, path.basename(txConfig.restarter.updateFileName)); + + this.isCheckingUpdateFile = true; + try { + //Check if the file exists and is a file + const fileStat = await fsp.stat(updateFilePath).catch(() => null); + if (!fileStat?.isFile()) return; + + //Read the content to use as restart reason (before deleting) + let reason: string | undefined; + try { + const raw = await fsp.readFile(updateFilePath, 'utf8'); + const sanitized = raw.replace(/\s+/g, ' ').trim().slice(0, UPDATE_FILE_REASON_MAX_LENGTH); + if (sanitized.length) reason = sanitized; + } catch (error) { + console.verbose.warn(`Failed to read update file content: ${(error as Error).message}`); + } + + //Delete the file first so we never loop on it, even if scheduling fails + await fsp.unlink(updateFilePath); + const logReason = reason ? ` (${reason})` : ''; + txCore.logger.admin.write('SCHEDULER', `Update file detected${logReason}, scheduling a restart.`); + + //Don't override an already pending temp restart + if (this.nextTempSchedule) { + console.verbose.log('A temporary restart is already scheduled, skipping the update file restart.'); + return; + } + + //Schedule the restart + try { + this.setNextTempSchedule(`+${txConfig.restarter.updateFileDelay}`, reason); + } catch (error) { + console.warn(`Update file detected but couldn't schedule a restart: ${(error as Error).message}`); + } + } catch (error) { + console.error(`Error while checking the update file: ${(error as Error).message}`); + } finally { + this.isCheckingUpdateFile = false; + } + } + + /** * Checks the schedule to see if it's time to announce or restart the server */ @@ -262,8 +335,10 @@ export default class FxScheduler { if (nextDistMins === 0) { //restart server this.triggerServerRestart( - `scheduled restart at ${nextRestart.string}`, - txCore.translator.t('restarter.schedule_reason', { time: nextRestart.string }), + nextRestart.reason + ? `update file restart (${nextRestart.reason})` + : `scheduled restart at ${nextRestart.string}`, + nextRestart.reason ?? txCore.translator.t('restarter.schedule_reason', { time: nextRestart.string }), ); //Check if server is in boot cooldown @@ -291,10 +366,12 @@ export default class FxScheduler { } }); - //Dispatch `txAdmin:events:scheduledRestart` + //Dispatch `txAdmin:events:scheduledRestart` + let translatedMessage = txCore.translator.t('restarter.schedule_warn', tOptions); + if (nextRestart.reason) translatedMessage += ` (${nextRestart.reason})`; txCore.fxRunner.sendEvent('scheduledRestart', { secondsRemaining: nextDistMins * 60, - translatedMessage: txCore.translator.t('restarter.schedule_warn', tOptions) + translatedMessage, }); } } diff --git a/core/routes/settings/saveConfigs.ts b/core/routes/settings/saveConfigs.ts index 724f76b55..44cf43e56 100644 --- a/core/routes/settings/saveConfigs.ts +++ b/core/routes/settings/saveConfigs.ts @@ -44,7 +44,8 @@ type CardHandler = ( //Known cards const cardNamesMap = { general: 'General', - fxserver: 'FXServer', + 'fxserver-settings': 'FXServer', + 'fxserver-ci-cd': 'CI/CD', bans: 'Bans', // FIXME:NEXT:UPDATE rename whitelist: 'Whitelist', @@ -106,7 +107,7 @@ export default async function SaveSettingsConfigs(ctx: AuthedCtx) { try { if (cardId === 'general') { handlerResp = await handleGeneralCard(inputConfig, sendTypedResp); - } else if (cardId === 'fxserver') { + } else if (cardId === 'fxserver-settings') { handlerResp = await handleFxserverCard(inputConfig, sendTypedResp); } else if (cardId === 'discord') { handlerResp = await handleDiscordCard(inputConfig, sendTypedResp); @@ -210,7 +211,7 @@ const handleFxserverCard: CardHandler = async (inputConfig, sendTypedResp) => { // inputConfig.server.dataPath = cleanPath(inputConfig.server.dataPath + '/'); // } if (typeof inputConfig.server?.dataPath !== 'string' || !inputConfig.server?.dataPath.length) { - throw new Error(`Unexpected data for the 'fxserver' card.`); + throw new Error(`Unexpected data for the 'fxserver-settings' card.`); } //Validating Server Data Path diff --git a/docs/auto-restart-on-update-file.md b/docs/auto-restart-on-update-file.md new file mode 100644 index 000000000..8f59e1b73 --- /dev/null +++ b/docs/auto-restart-on-update-file.md @@ -0,0 +1,72 @@ +# Auto-Restart on Update File (CI/CD) + +> An opt-in feature that lets txAdmin automatically schedule a server restart when a file appears in the server data folder. It is designed to make **CI/CD-based deployments** a one-step operation. + +## Overview + +When enabled, txAdmin periodically looks for an *update file* (named `.update` by default) in the root of the **Server Data folder** — the same folder that usually contains your `server.cfg`. If the file is found, txAdmin: + +1. Reads its content (optional). +2. Deletes the file. +3. Schedules a **temporary restart** after a configurable delay. + +The restart reuses txAdmin's existing scheduled-restart engine, so players still receive the usual countdown warnings (in-game and Discord) and the server is shut down gracefully. If the update file contains text (for example a version number or commit hash), that text is used as the restart message in the logs and in the in-game warning. + +## Motivation + +Servers that deploy updates through a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, a deploy script, etc.) need a reliable way to apply a new build. Restarting the process directly skips txAdmin's player warnings and graceful shutdown, and calling the API from a pipeline requires authentication and extra plumbing. + +This feature reduces the whole operation to **writing a single file** to disk after the deploy step. txAdmin takes care of warning players and restarting cleanly so the new files are loaded. + +## How It Works + +- On every scheduler tick (~60 seconds), if the feature is enabled **and the server is currently running**, txAdmin checks for `/`. +- If the file exists: + - Its content is read and sanitized (whitespace collapsed, trimmed, truncated to 150 characters) to be used as the restart reason. + - The file is **deleted first**, so the same file is never processed twice — even if scheduling fails. + - A temporary restart is scheduled for `+N` minutes via the existing restart scheduler. +- During the delay, players are warned at the standard intervals (30/15/10/5/4/3/2/1 minutes remaining), via both Discord and in-game announcements. +- When the timer reaches zero, the server is restarted gracefully. The update-file content (if any) is shown as the restart reason. + +## Configuration + +The options live under **Settings → FXServer → CI/CD**. + +| Setting | Config key | Type | Default | Description | +| --- | --- | --- | --- | --- | +| Auto-Restart on Update File | `restarter.updateFileEnabled` | boolean | `false` | Master switch for the feature. | +| Update File Restart Delay | `restarter.updateFileDelay` | number (minutes) | `2` | How long to wait before restarting after the file is detected. Range: 1–1439. Players are warned during this period. | +| Update File Name | `restarter.updateFileName` | string | `.update` | Name of the file txAdmin looks for, relative to the Server Data folder. | + +## Usage Example + +In your deployment pipeline, after copying the new files into place, write the update file: + +```bash +# After deploying your resources/artifacts... +echo "v1.4.2 ($(git rev-parse --short HEAD))" > /path/to/serverdata/.update +``` + +txAdmin will detect the file within ~60 seconds, warn players, and restart after the configured delay. Players (and the logs) will see the restart reason as `v1.4.2 (a1b2c3d)`. + +If you prefer no message, simply create an empty file: + +```bash +touch /path/to/serverdata/.update +``` + +## Behavior & Safeguards + +- **Only acts while the server is running.** If a deployment lands while the server is down, the update file is left untouched and processed once the server is back up. +- **The file is deleted before scheduling**, preventing restart loops if scheduling cannot proceed. +- **Existing temporary restarts are respected.** If a temporary restart is already pending, the update file is consumed but no new restart is scheduled (the pending one will happen anyway). +- **Path traversal is prevented** by resolving only the file's base name inside the Server Data folder. +- **Detection latency** is bounded by the scheduler tick (≤ ~60 seconds) plus the configured delay. +- **No new translation strings.** The feature reuses the existing scheduled-restart messages. The update-file content is appended to the in-game warning in parentheses. The Discord warning remains the standard scheduled-restart message. + +## Settings UI + +The FXServer settings tab is now split into two cards ("islands"), mirroring the existing **Game** tab layout (Menu / Notifications): + +- **FXServer → Settings** — the existing server settings (data folder, restart schedule, quiet mode, and advanced options). +- **FXServer → CI/CD** — the new auto-restart-on-update-file options. diff --git a/panel/src/pages/Settings/SettingsPage.tsx b/panel/src/pages/Settings/SettingsPage.tsx index cc74edbc1..13c95f895 100644 --- a/panel/src/pages/Settings/SettingsPage.tsx +++ b/panel/src/pages/Settings/SettingsPage.tsx @@ -15,6 +15,7 @@ import SettingsTab from "./SettingsTab"; import ConfigCardBans from "./tabCards/bans"; import ConfigCardDiscord from "./tabCards/discord"; import ConfigCardFxserver from "./tabCards/fxserver"; +import ConfigCardFxserverCICD from "./tabCards/fxserverCICD"; import ConfigCardGameMenu from "./tabCards/gameMenu"; import ConfigCardGameNotifications from "./tabCards/gameNotifications"; import ConfigCardGeneral from "./tabCards/general"; @@ -28,7 +29,13 @@ import { PageHeader, PageHeaderChangelog } from "@/components/page-header"; //Tab configuration const settingsTabsBase = [ { name: 'General', Component: ConfigCardGeneral }, //TODO: cards [Server Listing, txAdmin] - { name: 'FXServer', Component: ConfigCardFxserver }, + { + name: 'FXServer', + cards: [ + { name: 'Settings', Component: ConfigCardFxserver }, + { name: 'CI/CD', Component: ConfigCardFxserverCICD }, + ] + }, { name: 'Bans', Component: ConfigCardBans }, { name: 'Whitelist', Component: ConfigCardWhitelist }, { name: 'Discord', Component: ConfigCardDiscord }, diff --git a/panel/src/pages/Settings/tabCards/fxserverCICD.tsx b/panel/src/pages/Settings/tabCards/fxserverCICD.tsx new file mode 100644 index 000000000..86359b7ce --- /dev/null +++ b/panel/src/pages/Settings/tabCards/fxserverCICD.tsx @@ -0,0 +1,127 @@ +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import SwitchText from '@/components/SwitchText' +import InlineCode from '@/components/InlineCode' +import { SettingItem, SettingItemDesc } from '../settingsItems' +import { useEffect, useMemo, useReducer, useRef } from "react" +import { getConfigEmptyState, getConfigAccessors, SettingsCardProps, getPageConfig, configsReducer, getConfigDiff } from "../utils" +import SettingsCardShell from "../SettingsCardShell" +import { txToast } from "@/components/TxToaster" + + +export const pageConfigs = { + updateFileEnabled: getPageConfig('restarter', 'updateFileEnabled'), + updateFileDelay: getPageConfig('restarter', 'updateFileDelay'), + updateFileName: getPageConfig('restarter', 'updateFileName'), +} as const; + +export default function ConfigCardFxserverCICD({ cardCtx, pageCtx }: SettingsCardProps) { + const [states, dispatch] = useReducer( + configsReducer, + null, + () => getConfigEmptyState(pageConfigs), + ); + const cfg = useMemo(() => { + return getConfigAccessors(cardCtx.cardId, pageConfigs, pageCtx.apiData, dispatch); + }, [pageCtx.apiData, dispatch]); + + //Effects - handle changes + useEffect(() => { + updatePageState(); + }, [states]); + + //Refs for configs that don't use state + const updateFileNameRef = useRef(null); + + //Marshalling Utils + const selectNumberUtil = { + toUi: (num?: number) => num ? num.toString() : undefined, + toCfg: (str?: string) => str ? parseInt(str) : undefined, + } + + //Processes the state of the page and sets the card as pending save if needed + const updatePageState = () => { + const overwrites = { + updateFileName: updateFileNameRef.current?.value?.trim(), + }; + + const res = getConfigDiff(cfg, states, overwrites, false); + pageCtx.setCardPendingSave(res.hasChanges ? cardCtx : null); + return res; + } + + //Validate changes (for UX only) and trigger the save API + const handleOnSave = () => { + const { hasChanges, localConfigs } = updatePageState(); + if (!hasChanges) return; + + if (localConfigs.restarter?.updateFileName !== undefined && !localConfigs.restarter.updateFileName) { + return txToast.error({ + title: 'The Update File Name is required.', + md: true, + msg: 'The value should probably be `.update`.', + }); + } + pageCtx.saveChanges(cardCtx, localConfigs); + } + + return ( + + + + + When enabled, txAdmin will look for the file configured below in the Server Data folder. If found, the file is deleted and a server restart is scheduled.
+ This is useful for CI/CD pipelines: just drop the file in the folder after deploying your update. The file content (if any) is used as the restart message. +
+
+ + + + How long to wait before restarting after the update file is detected.
+ Players will be warned during this period, just like with a scheduled restart. +
+
+ + + + The name of the file txAdmin looks for, relative to the Server Data folder (next to your server.cfg).
+ Defaults to .update. +
+
+
+ ) +}