Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions core/modules/ConfigStore/schema/restarter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
89 changes: 83 additions & 6 deletions core/modules/FxScheduler.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
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';
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;
Expand Down Expand Up @@ -53,16 +60,18 @@ 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
setImmediate(() => {
this.checkSchedule();
});

//Cron Function
//Cron Function
setInterval(() => {
this.checkSchedule();
this.checkUpdateFile();
txCore.webServer.webSocket.pushRefresh('status');
}, 60 * 1000);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
7 changes: 4 additions & 3 deletions core/routes/settings/saveConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/auto-restart-on-update-file.md
Original file line number Diff line number Diff line change
@@ -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 `<ServerDataFolder>/<UpdateFileName>`.
- 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.
9 changes: 8 additions & 1 deletion panel/src/pages/Settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 },
Expand Down
Loading