diff --git a/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 b/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 new file mode 100644 index 0000000000..9c8505b504 --- /dev/null +++ b/projects/start-cli/man/start-cli-server-cancel-deferred-power.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-cli-server-cancel-deferred-power 1 "cancel-deferred-power " +.SH NAME +start\-cli\-server\-cancel\-deferred\-power \- Cancel a restart or shutdown that is waiting for a backup to finish +.SH SYNOPSIS +\fBstart\-cli server cancel\-deferred\-power\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Cancel a restart or shutdown that is waiting for a backup to finish +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/projects/start-cli/man/start-cli-server-restart.1 b/projects/start-cli/man/start-cli-server-restart.1 index d63e8f3749..2b2519167c 100644 --- a/projects/start-cli/man/start-cli-server-restart.1 +++ b/projects/start-cli/man/start-cli-server-restart.1 @@ -4,7 +4,7 @@ .SH NAME start\-cli\-server\-restart \- Restart the server .SH SYNOPSIS -\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Restart the server .SH OPTIONS @@ -12,5 +12,8 @@ Restart the server \fB\-\-nowait\fR Return immediately instead of waiting for graceful shutdown to complete .TP +\fB\-\-after\-backup\fR +Wait for a running backup to finish first +.TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-cli/man/start-cli-server-shutdown.1 b/projects/start-cli/man/start-cli-server-shutdown.1 index d80ff90135..058ef4681b 100644 --- a/projects/start-cli/man/start-cli-server-shutdown.1 +++ b/projects/start-cli/man/start-cli-server-shutdown.1 @@ -4,7 +4,7 @@ .SH NAME start\-cli\-server\-shutdown \- Shutdown the server .SH SYNOPSIS -\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Shutdown the server .SH OPTIONS @@ -12,5 +12,8 @@ Shutdown the server \fB\-\-nowait\fR Return immediately instead of waiting for graceful shutdown to complete .TP +\fB\-\-after\-backup\fR +Wait for a running backup to finish first +.TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/projects/start-cli/man/start-cli-server.1 b/projects/start-cli/man/start-cli-server.1 index 695db4709f..49218003df 100644 --- a/projects/start-cli/man/start-cli-server.1 +++ b/projects/start-cli/man/start-cli-server.1 @@ -13,6 +13,9 @@ Commands related to the server i.e. restart, update, and shutdown Print help .SH SUBCOMMANDS .TP +start\-cli\-server\-cancel\-deferred\-power(1) +Cancel a restart or shutdown that is waiting for a backup to finish +.TP start\-cli\-server\-clear\-smtp(1) Remove system smtp server and credentials .TP diff --git a/projects/start-os/ARCHITECTURE.md b/projects/start-os/ARCHITECTURE.md index b000f9a608..93a71bb8e7 100644 --- a/projects/start-os/ARCHITECTURE.md +++ b/projects/start-os/ARCHITECTURE.md @@ -85,6 +85,14 @@ erasure-coded FUSE filesystem used for StartOS backups. It builds to the `poweroff.target`/`halt.target`, not reboot); its `ExecStop` calls `start-cli server shutdown`. - `startos-restart.service` — restart handling. +- The physical power key is systemd-logind's (`HandlePowerKey=poweroff`), + except while a backup is running: `startd` then holds a logind + `handle-power-key` block inhibitor and reads the key itself, turning a press + into a shutdown that waits for the backup rather than one that interrupts it. + It is best-effort — when the inhibitor cannot be taken or no `power-switch` + device can be read, the key stays logind's — so treat it as one defence and + not a guarantee. See `start-core/src/power_key.rs` for why it inhibits + `handle-power-key` rather than `shutdown`. ## OS image packaging diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index bf9d8f46b9..637203d4b5 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -36,6 +36,20 @@ file tracks notable changes since the move to the monorepo. plaintext address, including the passwords typed into it. See [Gateways](https://docs.start9.com/start-os/gateways.html). +- **Restarting or shutting down while a backup is running now asks first, and + can wait for the backup to finish.** Powering the server off part-way through + a backup can corrupt the backup of whichever service is being written at that + moment. Choosing `Restart` or `Shutdown` during a backup now offers to wait + for the backup instead, and waiting is what happens if you walk away — the + prompt counts down and takes that option for you. StartOS then carries out + the restart or shutdown as soon as the backup completes, and until then a bar + along the bottom of the screen says what is coming and lets you cancel it. + Pressing the server's physical power button during a backup waits for the + backup too, rather than powering off immediately. Over the CLI, + `start-cli server restart` and `server shutdown` take `--after-backup` for + the same behavior and `start-cli server cancel-deferred-power` calls it off. + See [Creating Backups](https://docs.start9.com/start-os/backup-create.html). + ### Changed - **The NVIDIA images now use NVIDIA's open kernel modules, which support GeForce diff --git a/projects/start-os/docs/src/backup-create.md b/projects/start-os/docs/src/backup-create.md index fa6aaa70da..922627fb4a 100644 --- a/projects/start-os/docs/src/backup-create.md +++ b/projects/start-os/docs/src/backup-create.md @@ -21,6 +21,8 @@ Back up your server's data to a physical drive or a network folder. 1. To back up a service, StartOS first stops it (if it was running), performs the backup, then restarts it — but only if it was running beforehand. A service that was already stopped stays stopped. Consequently a service cannot be used while it is backing up, though you may continue to use your server and other services in the meantime. +1. Restarting or shutting down mid-backup can corrupt the backup of whichever service is being written at that moment, so StartOS asks first. Choosing `Restart` or `Shutdown` while a backup is running offers to wait for the backup to finish instead, and takes that option for you if you do not choose within 30 seconds — to power down regardless, choose the "now" option in that prompt. Pressing the server's physical power button during a backup waits for the backup rather than powering off, without asking. Either way StartOS performs the restart or shutdown as soon as the backup completes, and until then a bar along the bottom of the screen says what is coming and lets you cancel it. + 1. Upon completion, StartOS issues a backup report, indicating which services were backed up, as well as any errors. 1. Backups are differential — each new backup to the same target overwrites the previous one. To maintain multiple backup points, use multiple backup targets. diff --git a/projects/start-os/docs/src/cli-reference.md b/projects/start-os/docs/src/cli-reference.md index f26f53b285..a1bebcc31b 100644 --- a/projects/start-os/docs/src/cli-reference.md +++ b/projects/start-os/docs/src/cli-reference.md @@ -54,11 +54,21 @@ Restart, shut down, update, and configure the server. ### `start-cli server restart` -Restart the server. +Restart the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. + +- `--after-backup` — Wait for a running backup to finish first +- `--nowait` — Return immediately instead of waiting for graceful shutdown ### `start-cli server shutdown` -Shut down the server. +Shut down the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait. + +- `--after-backup` — Wait for a running backup to finish first +- `--nowait` — Return immediately instead of waiting for graceful shutdown + +### `start-cli server cancel-deferred-power` + +Cancel a restart or shutdown that is waiting for a backup to finish. ### `start-cli server update` diff --git a/projects/start-os/docs/src/surge-and-ups.md b/projects/start-os/docs/src/surge-and-ups.md index 001ae66fc2..296675747f 100644 --- a/projects/start-os/docs/src/surge-and-ups.md +++ b/projects/start-os/docs/src/surge-and-ups.md @@ -47,3 +47,6 @@ There are three common topologies. For a home server, **line-interactive** is th StartOS does not currently include built-in support for UPS monitoring (USB or network), so it cannot automatically shut down when the battery is low during an extended outage. The server will run until battery exhaustion and then power off uncleanly. This still carries some risk of data corruption, but it is dramatically less risky than facing the original surge, brownout, or sudden outage with no UPS at all. If your area has frequent or long outages, size your UPS to give yourself time to shut down manually from the StartOS UI before the battery runs out. + +> [!NOTE] +> If a backup is running when you do, StartOS offers to wait for the backup to finish and takes that option if you do not choose within 30 seconds — which on battery is rarely what you want. Choose `Shut down now` instead. The server's physical power button always waits for the backup, so on battery use the web UI rather than the button. diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts index 9b895140d6..ab74981378 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/menu.component.ts @@ -5,8 +5,8 @@ import { DocsLinkDirective, i18nPipe, SafeLinksDirective, - TaskService, } from '@start9labs/shared' +import { T } from '@start9labs/start-core' import { TuiButton, TuiDataList, @@ -17,6 +17,7 @@ import { import { filter } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { AuthService } from 'src/app/services/auth.service' +import { PowerService } from 'src/app/services/power.service' import { STATUS } from 'src/app/services/status.service' import { ABOUT } from './about.component' @@ -138,8 +139,8 @@ import { ABOUT } from './about.component' export class HeaderMenuComponent { private readonly api = inject(ApiService) private readonly auth = inject(AuthService) - private readonly tasks = inject(TaskService) private readonly dialog = inject(DialogService) + private readonly power = inject(PowerService) open = false @@ -149,7 +150,11 @@ export class HeaderMenuComponent { this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe() } - async promptPower(action: 'restart' | 'shutdown') { + async promptPower(action: T.PowerAction) { + // During a backup the choice on offer is a different one, and asking it is + // confirmation enough. + if (this.power.backingUp()) return this.power.power(action).subscribe() + this.dialog .openConfirm( action === 'restart' @@ -175,15 +180,7 @@ export class HeaderMenuComponent { }, ) .pipe(filter(Boolean)) - .subscribe(() => - this.tasks.run( - async () => - await this.api[ - action === 'restart' ? 'restartServer' : 'shutdownServer' - ]({}), - `Beginning ${action}`, - ), - ) + .subscribe(() => this.power.power(action).subscribe()) } logout() { diff --git a/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts new file mode 100644 index 0000000000..60c78e3df1 --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/components/header/power.component.ts @@ -0,0 +1,64 @@ +import { Component, signal } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { i18nPipe } from '@start9labs/shared' +import { T } from '@start9labs/start-core' +import { TuiButton, TuiDialogContext } from '@taiga-ui/core' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { take, timer } from 'rxjs' + +const COUNTDOWN = 30 + +@Component({ + template: ` +

+ {{ + 'A backup is currently running. Interrupting it now can corrupt the backup of the service being written.' + | i18n + }} +

+ @if (action === 'shutdown') { +

+ {{ + 'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in.' + | i18n + }} +

+ } + + `, + imports: [TuiButton, i18nPipe], +}) +export class PowerComponent { + private readonly context = + injectContext>() + + protected readonly action = this.context.data + protected readonly seconds = signal(COUNTDOWN) + + constructor() { + // One timer, so the choice is made exactly when the label says it will be. + timer(0, 1000) + .pipe(take(COUNTDOWN + 1), takeUntilDestroyed()) + .subscribe(tick => { + this.seconds.set(COUNTDOWN - tick) + if (tick === COUNTDOWN) this.wait() + }) + } + + protected now() { + this.context.completeWith(true) + } + + protected wait() { + this.context.completeWith(false) + } +} + +export const POWER = new PolymorpheusComponent(PowerComponent) diff --git a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts index 719622dac9..e039e0ebd9 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/portal.component.ts @@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { RouterOutlet } from '@angular/router' import { WA_IS_MOBILE } from '@ng-web-apis/platform' -import { i18nPipe, LeafProgressPipe, TaskService } from '@start9labs/shared' +import { i18nPipe, LeafProgressPipe } from '@start9labs/shared' import { TuiButton, TuiCell, @@ -14,10 +14,10 @@ import { import { TuiActionBar, TuiProgress } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' import { TabsComponent } from 'src/app/routes/portal/components/tabs.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' import { OSService } from 'src/app/services/os.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { PluginsService } from 'src/app/services/plugins.service' +import { PowerService } from 'src/app/services/power.service' import { HeaderComponent } from './components/header/header.component' @Component({ @@ -30,7 +30,32 @@ import { HeaderComponent } from './components/header/header.component' - @if (update(); as update) { + @if (deferredPower(); as action) { + + + + @if (action === 'restart') { + {{ + 'A backup is running. Your server will restart when it finishes.' + | i18n + }} + } @else { + {{ + 'A backup is running. Your server will shut down when it finishes.' + | i18n + }} + } + + + + } @else if (update(); as update) { @let leaf = update.overall | leafProgress; @@ -49,8 +74,7 @@ import { HeaderComponent } from './components/header/header.component' } - } - @if (restartReason(); as reason) { + } @else if (restartReason(); as reason) { @@ -167,9 +191,8 @@ import { HeaderComponent } from './components/header/header.component' ], }) export class PortalComponent { - private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) - private readonly api = inject(ApiService) + protected readonly power = inject(PowerService) readonly mobile = inject(WA_IS_MOBILE) readonly plugins = inject(PluginsService) @@ -177,6 +200,9 @@ export class PortalComponent { readonly restartReason = toSignal( this.patch.watch$('serverInfo', 'statusInfo', 'restart'), ) + readonly deferredPower = toSignal( + this.patch.watch$('serverInfo', 'statusInfo', 'deferredPowerAction'), + ) readonly bar = signal(true) getProgress(size: number, downloaded: number): number { @@ -184,9 +210,10 @@ export class PortalComponent { } restart() { - this.tasks.run(async () => { - this.bar.set(false) - await this.api.restartServer({}) - }, 'Beginning restart') + // Only stop offering the restart once one is actually under way — a + // deferred or dismissed one leaves the reason for this bar in place. + this.power.power('restart').subscribe(deferred => { + if (!deferred) this.bar.set(false) + }) } } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index 836e12ac40..75f39de4e8 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -44,6 +44,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { ConfigService } from 'src/app/services/config.service' import { OSService } from 'src/app/services/os.service' import { DataModel } from 'src/app/services/patch-db/data-model' +import { PowerService } from 'src/app/services/power.service' import { TitleDirective } from 'src/app/services/title.service' import { KeyboardSelectComponent } from './keyboard-select.component' import { ServerNameDialog } from './server-name.dialog' @@ -281,6 +282,7 @@ export default class SystemGeneralComponent { private readonly injector = inject(INJECTOR) private readonly win = inject(WA_WINDOW) private readonly config = inject(ConfigService) + private readonly power = inject(PowerService) count = 0 @@ -525,9 +527,6 @@ export default class SystemGeneralComponent { } private async restart() { - this.tasks.run( - async () => await this.api.restartServer({}), - 'Beginning restart', - ) + this.power.power('restart').subscribe() } } diff --git a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts index d572b674ab..921dafddde 100644 --- a/projects/start-os/web/ui/src/app/services/api/api.fixures.ts +++ b/projects/start-os/web/ui/src/app/services/api/api.fixures.ts @@ -27,6 +27,7 @@ export namespace Mock { restarting: false, shuttingDown: false, restart: null, + deferredPowerAction: null, } export const RegistryOSUpdate: T.OsVersionInfoMap = { diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts index 7833d4d13c..8f1e6ea951 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-api.service.ts @@ -115,9 +115,11 @@ export abstract class ApiService { targetVersion: string }): Promise<'updating' | 'no-updates'> - abstract restartServer(params: {}): Promise + abstract restartServer(params: Partial): Promise - abstract shutdownServer(params: {}): Promise + abstract shutdownServer(params: Partial): Promise + + abstract cancelDeferredPower(params: {}): Promise abstract repairDisk(params: {}): Promise diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts index 6b9b08e55b..5c131f623a 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-live-api.service.ts @@ -240,14 +240,18 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'server.update', params }) } - async restartServer(params: {}): Promise { + async restartServer(params: Partial): Promise { return this.rpcRequest({ method: 'server.restart', params }) } - async shutdownServer(params: {}): Promise { + async shutdownServer(params: Partial): Promise { return this.rpcRequest({ method: 'server.shutdown', params }) } + async cancelDeferredPower(params: {}): Promise { + return this.rpcRequest({ method: 'server.cancel-deferred-power', params }) + } + async repairDisk(params: {}): Promise { return this.rpcRequest({ method: 'disk.repair', params }) } diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index bf3d4f6c72..3e05ce4c91 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -101,6 +101,8 @@ const INIT_PROGRESS: T.FullProgress = { export class MockApiService extends ApiService { readonly mockWsSource$ = new Subject() private readonly revertTime = 1800 + private backingUp = false + private deferredPowerAction: T.PowerAction | null = null sequence = 0 constructor() { @@ -388,9 +390,14 @@ export class MockApiService extends ApiService { return 'updating' } - async restartServer(params: {}): Promise { + async restartServer(params: Partial): Promise { await pauseFor(2000) + if (params.afterBackup && this.backingUp) { + return this.deferPower('restart') + } + this.deferPower(null) + const patch = [ { op: PatchOp.REPLACE, @@ -414,9 +421,14 @@ export class MockApiService extends ApiService { return null } - async shutdownServer(params: {}): Promise { + async shutdownServer(params: Partial): Promise { await pauseFor(2000) + if (params.afterBackup && this.backingUp) { + return this.deferPower('shutdown') + } + this.deferPower(null) + const patch = [ { op: PatchOp.REPLACE, @@ -440,6 +452,11 @@ export class MockApiService extends ApiService { return null } + async cancelDeferredPower(params: {}): Promise { + await pauseFor(1000) + return this.deferPower(null) + } + async repairDisk(params: {}): Promise { await pauseFor(2000) return null @@ -922,6 +939,7 @@ export class MockApiService extends ApiService { async createBackup(params: T.BackupParams): Promise { await pauseFor(2000) + this.backingUp = true const serverPath = '/serverInfo/statusInfo/backupProgress' const ids = params.packageIds || [] // One phase per package plus a trailing "OS Data" phase (the host @@ -999,6 +1017,13 @@ export class MockApiService extends ApiService { }, ] this.mockRevision(lastPatch) + this.backingUp = false + if (this.deferredPowerAction) { + const action = this.deferredPowerAction + await this[action === 'restart' ? 'restartServer' : 'shutdownServer']( + {}, + ) + } // Feature 1: a completed backup whose target still holds a legacy (V1) // folder raises a warning notification — bumps the unread badge and @@ -2268,6 +2293,18 @@ export class MockApiService extends ApiService { this.mockRevision(patch) } + private deferPower(action: T.PowerAction | null): null { + this.deferredPowerAction = action + this.mockRevision([ + { + op: PatchOp.REPLACE, + path: '/serverInfo/statusInfo/deferredPowerAction', + value: action, + }, + ]) + return null + } + private mockData(path: string): any { const parts = path.split('/').filter(Boolean) let obj: any = mockPatchData diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts index 8dbe0483d9..2437c37d27 100644 --- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts +++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts @@ -261,6 +261,7 @@ export const mockPatchData: DataModel = { shuttingDown: false, backupProgress: null, restart: null, + deferredPowerAction: null, }, name: 'Random Words', hostname: 'random-words', diff --git a/projects/start-os/web/ui/src/app/services/power.service.ts b/projects/start-os/web/ui/src/app/services/power.service.ts new file mode 100644 index 0000000000..3eb49c87c3 --- /dev/null +++ b/projects/start-os/web/ui/src/app/services/power.service.ts @@ -0,0 +1,60 @@ +import { inject, Injectable } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { DialogService, TaskService } from '@start9labs/shared' +import { T } from '@start9labs/start-core' +import { defer, filter, map, Observable, switchMap } from 'rxjs' +import { POWER } from 'src/app/routes/portal/components/header/power.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { OSService } from 'src/app/services/os.service' + +@Injectable({ providedIn: 'root' }) +export class PowerService { + private readonly api = inject(ApiService) + private readonly dialog = inject(DialogService) + private readonly tasks = inject(TaskService) + readonly backingUp = toSignal(inject(OSService).backingUp$, { + initialValue: false, + }) + + /** + * Every in-app route to a restart or shutdown goes through here, so that none + * of them can interrupt a backup: during one the user is offered the choice + * of waiting for it, and the server keeps whichever choice is made. Emits + * once the server has been asked, `true` if the user chose to wait — not what + * the server then did, which the caller cannot see. A dismissed prompt asks + * for nothing and so emits nothing. + */ + power(action: T.PowerAction): Observable { + if (!this.backingUp()) return this.run(action, false) + + return this.dialog + .openComponent(POWER, { + label: action === 'restart' ? 'Restart' : 'Warning', + size: 's', + data: action, + }) + .pipe(switchMap(now => this.run(action, !now))) + } + + cancel() { + this.tasks.run(async () => await this.api.cancelDeferredPower({})) + } + + private run( + action: T.PowerAction, + afterBackup: boolean, + ): Observable { + return defer(() => + this.tasks.run( + async () => + action === 'restart' + ? await this.api.restartServer({ afterBackup }) + : await this.api.shutdownServer({ afterBackup }), + afterBackup ? 'Wait for backup to complete' : `Beginning ${action}`, + ), + ).pipe( + filter(Boolean), + map(() => afterBackup), + ) + } +} diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index e3c6dbedfc..c3431202cc 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -3157,6 +3157,13 @@ help.arg.gua-wan: fr_FR: "Exposer la GUA IPv6 au WAN (false = LAN uniquement)" pl_PL: "Udostępnij GUA IPv6 w sieci WAN (false = tylko LAN)" +help.arg.after-backup: + en_US: "Wait for a running backup to finish first" + de_DE: "Zuerst auf den Abschluss einer laufenden Sicherung warten" + es_ES: "Esperar primero a que termine una copia de seguridad en curso" + fr_FR: "Attendre d'abord la fin d'une sauvegarde en cours" + pl_PL: "Najpierw poczekaj na zakończenie trwającej kopii zapasowej" + help.arg.allow-model-mismatch: en_US: "Allow database model mismatch" de_DE: "Datenbankmodell-Abweichung erlauben" @@ -4750,6 +4757,13 @@ about.calculate-blake3-hash-for-file: fr_FR: "Calculer le hachage blake3 d'un fichier" pl_PL: "Oblicz hash blake3 dla pliku" +about.cancel-deferred-power: + en_US: "Cancel a restart or shutdown that is waiting for a backup to finish" + de_DE: "Einen Neustart oder ein Herunterfahren abbrechen, der bzw. das auf den Abschluss einer Sicherung wartet" + es_ES: "Cancelar un reinicio o apagado que está esperando a que termine una copia de seguridad" + fr_FR: "Annuler un redémarrage ou un arrêt en attente de la fin d'une sauvegarde" + pl_PL: "Anuluj ponowne uruchomienie lub wyłączenie oczekujące na zakończenie kopii zapasowej" + about.cancel-install-package: en_US: "Cancel an install of a package" de_DE: "Eine Paketinstallation abbrechen" diff --git a/shared-libs/crates/start-core/src/bins/startd.rs b/shared-libs/crates/start-core/src/bins/startd.rs index ffa16b0ffb..5b9d5f54a4 100644 --- a/shared-libs/crates/start-core/src/bins/startd.rs +++ b/shared-libs/crates/start-core/src/bins/startd.rs @@ -18,6 +18,7 @@ use crate::net::web_server::{Acceptor, WebServer}; use crate::prelude::*; use crate::shutdown::Shutdown; use crate::system::launch_metrics_task; +use crate::util::future::NonDetachingJoinHandle; use crate::util::io::append_file; use crate::util::logger::LOGGER; @@ -63,6 +64,23 @@ async fn inner_main( ) .await?; + // Every status here is written by a task that died with the previous + // process, and only `init` — which this branch skips — would otherwise + // clear them. `restart` is deliberately left: it is a reboot-needed + // marker meant to outlive one. Before the RPC surface goes live, so + // nothing races the reset. + ctx.db + .mutate(|db| { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + status.as_backup_progress_mut().ser(&None)?; + status.as_update_progress_mut().ser(&None)?; + status.as_deferred_power_action_mut().ser(&None)?; + status.as_shutting_down_mut().ser(&false)?; + status.as_restarting_mut().ser(&false) + }) + .await + .result?; + server.serve_ui_for(ctx.clone()); handle.complete(); @@ -103,6 +121,15 @@ async fn inner_main( .expect("send shutdown signal"); }); + let deferred_power_ctx = rpc_ctx.clone(); + let _deferred_power = NonDetachingJoinHandle::from(tokio::spawn( + crate::shutdown::run_deferred_power_actions(deferred_power_ctx), + )); + #[cfg(target_os = "linux")] + let _power_key = NonDetachingJoinHandle::from(tokio::spawn( + crate::power_key::watch_power_key(rpc_ctx.clone()), + )); + let metrics_ctx = rpc_ctx.clone(); let metrics_task = tokio::spawn(async move { launch_metrics_task(&metrics_ctx.metrics_cache, || { diff --git a/shared-libs/crates/start-core/src/db/model/public.rs b/shared-libs/crates/start-core/src/db/model/public.rs index ccd4635926..2f2d9b3d06 100644 --- a/shared-libs/crates/start-core/src/db/model/public.rs +++ b/shared-libs/crates/start-core/src/db/model/public.rs @@ -131,6 +131,7 @@ impl Public { shutting_down: false, restarting: false, restart: None, + deferred_power_action: None, }, unread_notification_count: 0, pubkey: ssh_key::PublicKey::from(&account.ssh_key) @@ -218,6 +219,14 @@ pub enum RestartReason { Update, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum PowerAction { + Restart, + Shutdown, +} + #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] @@ -451,6 +460,10 @@ pub struct ServerStatus { pub restarting: bool, #[serde(default)] pub restart: Option, + /// A restart or shutdown that was asked for while a backup was running, and + /// which StartOS carries out once the backup finishes. + #[serde(default)] + pub deferred_power_action: Option, } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] diff --git a/shared-libs/crates/start-core/src/init.rs b/shared-libs/crates/start-core/src/init.rs index bc91ee0b43..03d3707b17 100644 --- a/shared-libs/crates/start-core/src/init.rs +++ b/shared-libs/crates/start-core/src/init.rs @@ -396,6 +396,7 @@ pub async fn init( shutting_down: false, restarting: false, restart: None, + deferred_power_action: None, }; db.mutate(|v| { let server_info = v.as_public_mut().as_server_info_mut(); diff --git a/shared-libs/crates/start-core/src/lib.rs b/shared-libs/crates/start-core/src/lib.rs index 4726ac6908..e27846e6e7 100644 --- a/shared-libs/crates/start-core/src/lib.rs +++ b/shared-libs/crates/start-core/src/lib.rs @@ -67,6 +67,8 @@ pub mod middleware; pub mod net; pub mod notifications; pub mod os_install; +#[cfg(target_os = "linux")] +pub mod power_key; pub mod prelude; pub mod progress; pub mod registry; @@ -338,6 +340,13 @@ pub fn server() -> ParentHandler { .with_about("about.restart-server") .with_call_remote::(), ) + .subcommand( + "cancel-deferred-power", + from_fn_async(shutdown::cancel_deferred_power) + .no_display() + .with_about("about.cancel-deferred-power") + .with_call_remote::(), + ) .subcommand( "rebuild", from_fn_async(shutdown::rebuild) diff --git a/shared-libs/crates/start-core/src/power_key.rs b/shared-libs/crates/start-core/src/power_key.rs new file mode 100644 index 0000000000..7161095b8b --- /dev/null +++ b/shared-libs/crates/start-core/src/power_key.rs @@ -0,0 +1,273 @@ +//! The case power button, while a backup is running. +//! +//! systemd-logind powers the server off the moment the power key is pressed, +//! which cuts a running backup off mid-write and can corrupt the service being +//! written. For as long as a backup is underway startd therefore holds a logind +//! `block` inhibitor on `handle-power-key`, and reads the key itself so the +//! press still means something: it records a deferred shutdown, which the web +//! UI surfaces and which StartOS carries out once the backup finishes. +//! +//! It names `handle-power-key` and not `shutdown` on purpose — blocking +//! `shutdown` would also block the power-off StartOS itself asks systemd for at +//! the end of a graceful teardown. Both the inhibitor and the readers live for +//! one backup and are set up again for the next, so a device that came or went +//! in between is picked up and one that failed does not stand the feature down +//! for good. Failure gives the button back to logind rather than taking it +//! away. + +use std::io::Read; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +use futures::FutureExt; +use futures::future::select_all; +use nix::sys::stat; +use patch_db::TypedDbWatch; +use patch_db::json_ptr::JsonPointer; +use tokio::io::unix::AsyncFd; +use zbus::proxy; +use zbus::zvariant::OwnedFd; + +use crate::context::RpcContext; +use crate::db::model::public::{PowerAction, ServerStatus}; +use crate::prelude::*; +use crate::shutdown::{STATUS_INFO_PTR, defer_until_backup_complete}; +use crate::sound::BEP; + +const EV_KEY: u16 = 0x01; +/// Both codes logind acts on, handled by one arm of its own `case` — matching +/// that is what keeps a press meaning here what it would have meant to logind. +const KEY_POWER: [u16; 2] = [116, 356]; +const KEY_PRESSED: i32 = 1; + +const EVENT_SIZE: usize = std::mem::size_of::(); +/// Offset of `struct input_event`'s trailing `type`, `code` and `value`. The +/// leading timestamp's width varies by architecture; those three fields are +/// always the last 8 bytes. +const EVENT_TAIL: usize = EVENT_SIZE - 8; + +const POWER_SWITCH_TAG_DIR: &str = "/run/udev/tags/power-switch"; + +#[proxy( + interface = "org.freedesktop.login1.Manager", + default_service = "org.freedesktop.login1", + default_path = "/org/freedesktop/login1" +)] +trait Login1Manager { + /// The returned file descriptor *is* the lock — it is released when dropped. + fn inhibit(&self, what: &str, who: &str, why: &str, mode: &str) -> Result; +} + +pub async fn watch_power_key(ctx: RpcContext) { + let manager = match logind().await { + Ok(manager) => manager, + Err(e) => { + tracing::warn!("cannot reach systemd-logind: {e}"); + tracing::debug!("{e:?}"); + return; + } + }; + let mut watch = ctx + .db + .watch(STATUS_INFO_PTR.parse::().unwrap()) + .await + .typed::(); + if let Err(e) = guard_backups(&ctx, &manager, &mut watch).await { + tracing::error!("stopped guarding backups from the power button: {e}"); + tracing::debug!("{e:?}"); + } +} + +async fn guard_backups( + ctx: &RpcContext, + manager: &Login1ManagerProxy<'_>, + watch: &mut TypedDbWatch, +) -> Result<(), Error> { + loop { + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_some()) + .await?; + // Every way of failing to guard one backup leaves the key to logind for + // that backup only; the next one sets up from scratch. + if let Err(e) = guard_backup(ctx, manager, watch).await { + tracing::error!("not guarding this backup from the power button: {e}"); + tracing::debug!("{e:?}"); + } + watch + .wait_for(|status: &ServerStatus| status.backup_progress.is_none()) + .await?; + } +} + +async fn guard_backup( + ctx: &RpcContext, + manager: &Login1ManagerProxy<'_>, + watch: &mut TypedDbWatch, +) -> Result<(), Error> { + // Enumerated per backup rather than once: udev tags every key-capable + // device, so the set changes whenever a keyboard is plugged in. + let devices = power_key_devices().await?; + if devices.is_empty() { + tracing::info!("no power-switch input device to read the power key from"); + return Ok(()); + } + let lock = manager + .inhibit( + "handle-power-key", + "StartOS", + "A backup is running", + "block", + ) + .await?; + let backup_over = watch.wait_for(|status: &ServerStatus| status.backup_progress.is_none()); + tokio::pin!(backup_over); + tokio::select! { + over = &mut backup_over => { over?; } + // Never inhibit a key nobody is reading. + _ = read_power_key(devices, ctx) => tracing::warn!( + "stopped reading the power key for this backup; systemd-logind has it back" + ), + } + drop(lock); + Ok(()) +} + +/// Returns as soon as any one device stops being readable: there is no telling +/// which of them the firmware reports presses on, so a partial failure has to +/// count as a failure. +async fn read_power_key(devices: Vec, ctx: &RpcContext) { + select_all( + devices + .into_iter() + .map(|path| read_device(path, ctx.clone()).boxed()), + ) + .await; +} + +async fn read_device(path: PathBuf, ctx: RpcContext) { + let device = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(&path) + .and_then(AsyncFd::new); + let device = match device { + Ok(device) => device, + Err(e) => { + tracing::warn!("could not read the power key from {}: {e}", path.display()); + return; + } + }; + // evdev only ever hands back whole events, so a batch never splits one. + let mut buf = [0u8; EVENT_SIZE * 16]; + loop { + let read = match device.readable().await { + Ok(mut guard) => guard.try_io(|fd| { + let mut file = fd.get_ref(); + file.read(&mut buf) + }), + Err(e) => { + tracing::warn!("stopped reading {}: {e}", path.display()); + return; + } + }; + match read { + Err(_would_block) => continue, + Ok(Err(e)) => { + tracing::warn!("stopped reading {}: {e}", path.display()); + return; + } + Ok(Ok(len)) => { + for event in buf[..len].chunks_exact(EVENT_SIZE) { + if is_power_key_press(event) { + on_power_key(&ctx).await; + } + } + } + } + } +} + +async fn on_power_key(ctx: &RpcContext) { + match defer_until_backup_complete(ctx, PowerAction::Shutdown).await { + Ok(true) => { + tracing::info!("power key pressed during a backup; shutting down once it finishes"); + // The only feedback whoever pressed it has. Spawned so a contended + // sound device cannot stall the reader. + tokio::spawn(async { BEP.play().await.log_err() }); + } + Ok(false) => (), + Err(e) => { + tracing::error!("could not defer the shutdown for the running backup: {e}"); + tracing::debug!("{e:?}"); + } + } +} + +async fn logind() -> Result, Error> { + Ok(Login1ManagerProxy::new(&zbus::Connection::system().await?).await?) +} + +/// The devices systemd-logind itself treats as power switches: udev tags them +/// `power-switch`, and names each one by device number in that tag's directory. +/// Reading logind's own device set rather than picking devices by capability is +/// what keeps a press meaning here exactly what it would have meant to logind. +async fn power_key_devices() -> Result, Error> { + let mut devices = Vec::new(); + let mut dir = tokio::fs::read_dir("/dev/input").await?; + while let Some(entry) = dir.next_entry().await? { + if !entry.file_name().as_encoded_bytes().starts_with(b"event") { + continue; + } + let id = device_id(entry.metadata().await?.rdev()); + if Path::new(POWER_SWITCH_TAG_DIR).join(id).exists() { + devices.push(entry.path()); + } + } + Ok(devices) +} + +fn device_id(rdev: u64) -> String { + format!("c{}:{}", stat::major(rdev), stat::minor(rdev)) +} + +fn is_power_key_press(event: &[u8]) -> bool { + let tail = &event[EVENT_TAIL..]; + u16::from_ne_bytes([tail[0], tail[1]]) == EV_KEY + && KEY_POWER.contains(&u16::from_ne_bytes([tail[2], tail[3]])) + && i32::from_ne_bytes([tail[4], tail[5], tail[6], tail[7]]) == KEY_PRESSED +} + +#[cfg(test)] +mod test { + use super::*; + + /// `/dev/input/event0` is char device 13:64, and udev names its tag entry + /// after exactly that. + #[test] + fn names_a_device_the_way_udev_tags_it() { + assert_eq!(device_id(stat::makedev(13, 64)), "c13:64"); + assert_eq!(device_id(stat::makedev(13, 71)), "c13:71"); + } + + #[test] + fn recognizes_a_power_key_press() { + let mut event = [0u8; EVENT_SIZE]; + event[EVENT_TAIL..EVENT_TAIL + 2].copy_from_slice(&EV_KEY.to_ne_bytes()); + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&KEY_POWER[0].to_ne_bytes()); + event[EVENT_TAIL + 4..].copy_from_slice(&KEY_PRESSED.to_ne_bytes()); + assert!(is_power_key_press(&event)); + + // KEY_POWER2, which logind acts on identically. + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&KEY_POWER[1].to_ne_bytes()); + assert!(is_power_key_press(&event)); + + // A release, which must not power anything off. + event[EVENT_TAIL + 4..].copy_from_slice(&0i32.to_ne_bytes()); + assert!(!is_power_key_press(&event)); + + // Some other key. + event[EVENT_TAIL + 2..EVENT_TAIL + 4].copy_from_slice(&30u16.to_ne_bytes()); + event[EVENT_TAIL + 4..].copy_from_slice(&KEY_PRESSED.to_ne_bytes()); + assert!(!is_power_key_press(&event)); + } +} diff --git a/shared-libs/crates/start-core/src/shutdown.rs b/shared-libs/crates/start-core/src/shutdown.rs index 50ae71f855..3eeab622c8 100644 --- a/shared-libs/crates/start-core/src/shutdown.rs +++ b/shared-libs/crates/start-core/src/shutdown.rs @@ -1,9 +1,14 @@ +use std::time::Duration; + use clap::Parser; +use patch_db::json_ptr::JsonPointer; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::PLATFORM; use crate::context::RpcContext; +use crate::db::model::DatabaseModel; +use crate::db::model::public::{PowerAction, ServerStatus}; use crate::disk::main::export; use crate::init::{STANDBY_MODE_PATH, SYSTEM_REBUILD_PATH}; use crate::prelude::*; @@ -119,7 +124,7 @@ fn systemd_is_stopping() -> bool { .unwrap_or(false) } -#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, Parser, TS)] #[group(skip)] #[ts(export)] #[serde(rename_all = "camelCase")] @@ -129,11 +134,24 @@ pub struct ShutdownParams { /// frontend omits this and gets an immediate reply). Cleared with /// `--nowait`. The wait can't outlive the webserver teardown that follows /// container shutdown, so the connection drops once services are stopped. + /// Nothing is waited for when `--after-backup` defers the action, since + /// there is no teardown yet to wait on. #[arg(long = "nowait", action = clap::ArgAction::SetFalse, help = "help.arg.nowait")] #[serde(default)] wait: bool, + /// Let a running backup finish first, rather than interrupting it. Off by + /// default, so the systemd units that drive a real power-off — which cannot + /// wait — keep their existing behavior. + #[arg(long = "after-backup", help = "help.arg.after-backup")] + #[serde(default)] + after_backup: bool, } +pub(crate) const STATUS_INFO_PTR: &str = "/public/serverInfo/statusInfo"; +/// How long to leave a failing patch-db alone before trying to take the +/// deferred action again. +const TAKE_RETRY: Duration = Duration::from_secs(30); + async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { ctx.shutdown .send(Some(Shutdown { @@ -147,43 +165,284 @@ async fn begin_shutdown(ctx: &RpcContext, restart: bool, wait: bool) { } } +/// Records `action` as the deferred power action if a backup is underway, and +/// reports whether it did. Unlike [`defer_or_begin`] it never performs the +/// action, which is what the power key needs: with no backup to wait for, the +/// press is logind's to act on. +pub async fn defer_until_backup_complete( + ctx: &RpcContext, + action: PowerAction, +) -> Result { + ctx.db + .mutate(|db| defer_if_backing_up(db, action)) + .await + .result +} + +fn defer_if_backing_up(db: &mut DatabaseModel, action: PowerAction) -> Result { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if status.as_backup_progress().transpose_ref().is_none() { + return Ok(false); + } + status.as_deferred_power_action_mut().ser(&Some(action))?; + Ok(true) +} + +/// Either records `action` for after the backup, or commits to performing it +/// now — in one mutation, so a backup cannot start in the window between +/// deciding and acting. Returns whether it was deferred. +async fn defer_or_begin( + ctx: &RpcContext, + action: PowerAction, + after_backup: bool, +) -> Result { + ctx.db + .mutate(|db| defer_or_begin_in(db, action, after_backup)) + .await + .result +} + +fn defer_or_begin_in( + db: &mut DatabaseModel, + action: PowerAction, + after_backup: bool, +) -> Result { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + if after_backup && status.as_backup_progress().transpose_ref().is_some() { + status.as_deferred_power_action_mut().ser(&Some(action))?; + return Ok(true); + } + status.as_deferred_power_action_mut().ser(&None)?; + match action { + PowerAction::Restart => status.as_restarting_mut().ser(&true)?, + PowerAction::Shutdown => status.as_shutting_down_mut().ser(&true)?, + } + Ok(false) +} + +/// Reads the deferred action and clears it in one breath, so a cancellation that +/// lands first wins and the caller performs nothing. +fn take_deferred(db: &mut DatabaseModel) -> Result, Error> { + let status = db.as_public_mut().as_server_info_mut().as_status_info_mut(); + let action = status.as_deferred_power_action().de()?; + status.as_deferred_power_action_mut().ser(&None)?; + Ok(action) +} + +/// Carries out each deferred power action once the backup it was waiting on +/// finishes. Runs for the lifetime of startd: an action can be recorded at any +/// point during any backup — from the web UI, the CLI, or the power button — so +/// this must survive one having failed. +pub async fn run_deferred_power_actions(ctx: RpcContext) { + let mut watch = ctx + .db + .watch(STATUS_INFO_PTR.parse::().unwrap()) + .await + .typed::(); + loop { + if let Err(e) = watch + .wait_for(|status| { + status.deferred_power_action.is_some() && status.backup_progress.is_none() + }) + .await + { + // The db is gone, so there is nothing left to retry against. + tracing::error!("stopped watching for deferred power actions: {e}"); + tracing::debug!("{e:?}"); + return; + } + let taken = ctx.db.mutate(take_deferred).await.result; + let action = match taken { + Ok(action) => action, + Err(e) => { + // A failed mutation leaves the db untouched, so retrying + // immediately would spin against whatever is failing. + tracing::error!("could not take the deferred power action: {e}"); + tracing::debug!("{e:?}"); + tokio::time::sleep(TAKE_RETRY).await; + continue; + } + }; + // Still `after_backup`, so a backup that started since the take is + // waited for in turn rather than interrupted. + let params = ShutdownParams { + wait: false, + after_backup: true, + }; + let performed = match action { + Some(PowerAction::Restart) => { + tracing::info!("backup finished; carrying out the deferred restart"); + restart(ctx.clone(), params).await + } + Some(PowerAction::Shutdown) => { + tracing::info!("backup finished; carrying out the deferred shutdown"); + shutdown(ctx.clone(), params).await + } + None => continue, + }; + if let Err(e) = performed { + tracing::error!("deferred power action failed: {e}"); + tracing::debug!("{e:?}"); + // Put it back rather than losing it, and give whatever failed room + // to recover before trying again. Not via `defer_or_begin`: with the + // backup already over it would commit to performing the action + // instead of recording it. + if let Some(action) = action { + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_status_info_mut() + .as_deferred_power_action_mut() + .ser(&Some(action)) + }) + .await + .result + .log_err(); + } + tokio::time::sleep(TAKE_RETRY).await; + } + } +} + pub async fn shutdown( ctx: RpcContext, - ShutdownParams { wait }: ShutdownParams, + ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { - ctx.db - .mutate(|db| { - db.as_public_mut() - .as_server_info_mut() - .as_status_info_mut() - .as_shutting_down_mut() - .ser(&true) - }) - .await - .result?; + if defer_or_begin(&ctx, PowerAction::Shutdown, after_backup).await? { + return Ok(()); + } begin_shutdown(&ctx, false, wait).await; Ok(()) } pub async fn restart( ctx: RpcContext, - ShutdownParams { wait }: ShutdownParams, + ShutdownParams { wait, after_backup }: ShutdownParams, ) -> Result<(), Error> { + if defer_or_begin(&ctx, PowerAction::Restart, after_backup).await? { + return Ok(()); + } + begin_shutdown(&ctx, true, wait).await; + Ok(()) +} + +pub async fn cancel_deferred_power(ctx: RpcContext) -> Result<(), Error> { ctx.db .mutate(|db| { db.as_public_mut() .as_server_info_mut() .as_status_info_mut() - .as_restarting_mut() - .ser(&true) + .as_deferred_power_action_mut() + .ser(&None) }) .await - .result?; - begin_shutdown(&ctx, true, wait).await; - Ok(()) + .result } pub async fn rebuild(ctx: RpcContext) -> Result<(), Error> { tokio::fs::write(SYSTEM_REBUILD_PATH, b"").await?; - restart(ctx, ShutdownParams { wait: false }).await + restart(ctx, ShutdownParams::default()).await +} + +#[cfg(test)] +mod test { + use imbl_value::json; + use patch_db::ModelExt; + + use super::*; + + fn db_with(backup_progress: Value, deferred: Value) -> DatabaseModel { + DatabaseModel::from_value(json!({ + "public": { "serverInfo": { "statusInfo": { + "backupProgress": backup_progress, + "updateProgress": null, + "shuttingDown": false, + "restarting": false, + "restart": null, + "deferredPowerAction": deferred, + } } } + })) + } + + fn backing_up() -> Value { + json!({ "overall": { "done": 0, "total": 2, "units": null }, "phases": [] }) + } + + /// `(deferred action, shutting down, restarting)`. + fn status(db: &DatabaseModel) -> (Option, bool, bool) { + let status = db.as_public().as_server_info().as_status_info(); + ( + status.as_deferred_power_action().de().unwrap(), + status.as_shutting_down().de().unwrap(), + status.as_restarting().de().unwrap(), + ) + } + + #[test] + fn records_the_action_instead_of_beginning_it_during_a_backup() { + let mut db = db_with(backing_up(), json!(null)); + assert!(defer_or_begin_in(&mut db, PowerAction::Shutdown, true).unwrap()); + assert_eq!( + status(&db), + (Some(PowerAction::Shutdown), false, false), + "recorded, and nothing has begun" + ); + } + + #[test] + fn begins_the_action_when_no_backup_is_running() { + let mut db = db_with(json!(null), json!(null)); + assert!(!defer_or_begin_in(&mut db, PowerAction::Restart, true).unwrap()); + assert_eq!(status(&db), (None, false, true)); + } + + /// The systemd units drive a power-off that cannot wait, so they pass + /// `after_backup: false` and must interrupt the backup. + #[test] + fn begins_the_action_without_after_backup_even_during_a_backup() { + let mut db = db_with(backing_up(), json!(null)); + assert!(!defer_or_begin_in(&mut db, PowerAction::Shutdown, false).unwrap()); + assert_eq!(status(&db), (None, true, false)); + } + + /// Why [`run_deferred_power_actions`] cannot re-arm through this function: + /// with the backup over it takes the other branch and commits to the action, + /// which as a re-arm would leave the server flagged as powering down with + /// nothing left to do it. + #[test] + fn beginning_an_action_clears_any_pending_one() { + let mut db = db_with(json!(null), json!("restart")); + assert!(!defer_or_begin_in(&mut db, PowerAction::Shutdown, true).unwrap()); + assert_eq!(status(&db), (None, true, false)); + } + + #[test] + fn the_power_key_records_but_never_begins() { + let mut db = db_with(backing_up(), json!(null)); + assert!(defer_if_backing_up(&mut db, PowerAction::Shutdown).unwrap()); + assert_eq!(status(&db), (Some(PowerAction::Shutdown), false, false)); + + let mut db = db_with(json!(null), json!(null)); + assert!(!defer_if_backing_up(&mut db, PowerAction::Shutdown).unwrap()); + assert_eq!( + status(&db), + (None, false, false), + "no backup to protect, so the press is logind's to act on" + ); + } + + #[test] + fn taking_the_action_clears_it_so_only_one_pass_performs_it() { + let mut db = db_with(json!(null), json!("restart")); + assert_eq!(take_deferred(&mut db).unwrap(), Some(PowerAction::Restart)); + assert_eq!(take_deferred(&mut db).unwrap(), None); + } + + /// A cancellation that lands before the take wins outright. + #[test] + fn taking_a_cancelled_action_yields_nothing() { + let mut db = db_with(json!(null), json!(null)); + assert_eq!(take_deferred(&mut db).unwrap(), None); + } } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts index e841e281f0..045aee4063 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts @@ -805,4 +805,9 @@ export default { 912: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk befinden sich auf einer Partition neben einer älteren OS-Installation und können auf diesem Gerät nicht beibehalten werden. Um das Laufwerk zu löschen und neu zu beginnen, wählen Sie "Überschreiben".', 913: 'Die StartOS-Daten auf dem ausgewählten Datenlaufwerk erstrecken sich über das gesamte Laufwerk, sodass das OS nicht auf demselben Laufwerk installiert werden kann, ohne sie zu löschen. Um Ihre Daten zu behalten, wählen Sie ein anderes OS-Laufwerk. Um sie zu löschen, wählen Sie "Überschreiben".', 914: 'Anmeldung erfolgreich, aber der Server hat den neuen Geräteschlüssel abgelehnt. Versuchen Sie es erneut.', + 915: 'Derzeit läuft eine Sicherung. Eine Unterbrechung kann jetzt die Sicherung des gerade geschriebenen Dienstes beschädigen.', + 916: 'Auf Abschluss der Sicherung warten', + 917: 'Jetzt herunterfahren', + 918: 'Eine Sicherung läuft. Ihr Server wird nach deren Abschluss neu gestartet.', + 919: 'Eine Sicherung läuft. Ihr Server wird nach deren Abschluss heruntergefahren.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts index ef80bbe69d..fd8adfb721 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts @@ -806,4 +806,9 @@ export const ENGLISH: Record = { 'The StartOS data on the selected data drive is stored on a partition alongside an older OS installation, and cannot be preserved on this device. To erase the drive and start fresh, choose "Overwrite".': 912, 'The StartOS data on the selected data drive spans the entire drive, so the OS cannot be installed to the same drive without erasing it. To preserve your data, select a different OS drive. To erase it, choose "Overwrite".': 913, 'Login succeeded, but the server rejected the new device key. Try again.': 914, + 'A backup is currently running. Interrupting it now can corrupt the backup of the service being written.': 915, + 'Wait for backup to complete': 916, + 'Shut down now': 917, + 'A backup is running. Your server will restart when it finishes.': 918, + 'A backup is running. Your server will shut down when it finishes.': 919, } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts index 1a206e4410..5dbcb2fbe2 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts @@ -805,4 +805,9 @@ export default { 912: 'Los datos de StartOS en la unidad de datos seleccionada están en una partición junto a una instalación de SO anterior, y no pueden conservarse en este dispositivo. Para borrar la unidad y empezar de nuevo, elija "Sobrescribir".', 913: 'Los datos de StartOS en la unidad de datos seleccionada ocupan toda la unidad, por lo que el SO no puede instalarse en la misma unidad sin borrarlos. Para conservar sus datos, seleccione otra unidad para el SO. Para borrarlos, elija "Sobrescribir".', 914: 'Inicio de sesión correcto, pero el servidor rechazó la nueva clave del dispositivo. Inténtelo de nuevo.', + 915: 'Hay una copia de seguridad en curso. Interrumpirla ahora puede dañar la copia de seguridad del servicio que se está escribiendo.', + 916: 'Esperar a que termine la copia de seguridad', + 917: 'Apagar ahora', + 918: 'Hay una copia de seguridad en curso. Su servidor se reiniciará cuando termine.', + 919: 'Hay una copia de seguridad en curso. Su servidor se apagará cuando termine.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts index 957b12fe0e..2bd6a20364 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts @@ -805,4 +805,9 @@ export default { 912: 'Les données StartOS du disque de données sélectionné se trouvent sur une partition aux côtés d’une ancienne installation de l’OS et ne peuvent pas être conservées sur cet appareil. Pour effacer le disque et repartir à zéro, choisissez « Écraser ».', 913: 'Les données StartOS du disque de données sélectionné occupent l’intégralité du disque : l’OS ne peut donc pas être installé sur le même disque sans les effacer. Pour conserver vos données, sélectionnez un autre disque pour l’OS. Pour les effacer, choisissez « Écraser ».', 914: 'Connexion réussie, mais le serveur a rejeté la nouvelle clé de l’appareil. Réessayez.', + 915: 'Une sauvegarde est en cours. L’interrompre maintenant peut corrompre la sauvegarde du service en cours d’écriture.', + 916: 'Attendre la fin de la sauvegarde', + 917: 'Éteindre maintenant', + 918: 'Une sauvegarde est en cours. Votre serveur redémarrera une fois celle-ci terminée.', + 919: 'Une sauvegarde est en cours. Votre serveur s’éteindra une fois celle-ci terminée.', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts index 73db1061e3..f24dc46d50 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts @@ -805,4 +805,9 @@ export default { 912: 'Dane StartOS na wybranym dysku danych znajdują się na partycji obok starszej instalacji systemu i nie można ich zachować na tym urządzeniu. Aby wymazać dysk i zacząć od nowa, wybierz „Nadpisz”.', 913: 'Dane StartOS na wybranym dysku danych zajmują cały dysk, więc systemu nie można zainstalować na tym samym dysku bez ich wymazania. Aby zachować dane, wybierz inny dysk systemowy. Aby je wymazać, wybierz „Nadpisz”.', 914: 'Logowanie powiodło się, ale serwer odrzucił nowy klucz urządzenia. Spróbuj ponownie.', + 915: 'Trwa tworzenie kopii zapasowej. Przerwanie jej teraz może uszkodzić kopię zapasową aktualnie zapisywanej usługi.', + 916: 'Poczekaj na zakończenie kopii zapasowej', + 917: 'Wyłącz teraz', + 918: 'Trwa tworzenie kopii zapasowej. Serwer zostanie ponownie uruchomiony po jej zakończeniu.', + 919: 'Trwa tworzenie kopii zapasowej. Serwer zostanie wyłączony po jej zakończeniu.', } satisfies i18n diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts b/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts new file mode 100644 index 0000000000..008c8992db --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/osBindings/PowerAction.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PowerAction = 'restart' | 'shutdown' diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts index 94cf76c898..783ce3296a 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ServerStatus.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FullProgress } from './FullProgress' +import type { PowerAction } from './PowerAction' import type { RestartReason } from './RestartReason' export type ServerStatus = { @@ -8,4 +9,9 @@ export type ServerStatus = { shuttingDown: boolean restarting: boolean restart: RestartReason | null + /** + * A restart or shutdown that was asked for while a backup was running, and + * which StartOS carries out once the backup finishes. + */ + deferredPowerAction: PowerAction | null } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts index 21a7d7c0d9..259fc202fd 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/ShutdownParams.ts @@ -6,6 +6,14 @@ export type ShutdownParams = { * frontend omits this and gets an immediate reply). Cleared with * `--nowait`. The wait can't outlive the webserver teardown that follows * container shutdown, so the connection drops once services are stopped. + * Nothing is waited for when `--after-backup` defers the action, since + * there is no teardown yet to wait on. */ wait: boolean + /** + * Let a running backup finish first, rather than interrupting it. Off by + * default, so the systemd units that drive a real power-off — which cannot + * wait — keep their existing behavior. + */ + afterBackup: boolean } diff --git a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts index 99c95a435f..566038c294 100644 --- a/shared-libs/ts-modules/start-core/lib/osBindings/index.ts +++ b/shared-libs/ts-modules/start-core/lib/osBindings/index.ts @@ -228,6 +228,7 @@ export { Percentage } from './Percentage' export { PluginHostnameInfo } from './PluginHostnameInfo' export { PluginId } from './PluginId' export { PortForward } from './PortForward' +export { PowerAction } from './PowerAction' export { Progress } from './Progress' export { ProgressUnits } from './ProgressUnits' export { ProxyAuth } from './ProxyAuth'