Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@
"description": "Shut down, restart or otherwise power-control a machine (remote).",
"configKeys": [
{ "key": "action", "type": "string", "required": true, "description": "shutdown | restart | logoff | abort | hibernate." },
{ "key": "delaySeconds", "type": "number", "required": false, "description": "Delay before the action." },
{ "key": "force", "type": "boolean", "required": false, "description": "Force running applications to close." },
{ "key": "delaySeconds", "type": "number", "required": false, "description": "Shutdown/restart countdown in seconds, default 0. Positive values require force=true. To wait with force=false, use a separate Delay step and set delaySeconds=0." },
{ "key": "force", "type": "boolean", "required": false, "description": "Force running applications to close, default true. When false, delaySeconds must be 0." },
{ "key": "message", "type": "string", "required": false, "description": "Message shown to logged-on users." }
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace NodePilot.Engine.Activities;
///
/// Config:
/// action string, required — "shutdown" | "restart" | "logoff" | "abort" | "hibernate"
/// delaySeconds int, default 0 — /t N countdown before execution (shutdown/restart only)
/// delaySeconds int, default 0 — /t N countdown (shutdown/restart only; positive values require force)
/// force bool, default true — /f, closes running apps without prompting
/// message optional shutdown or restart dialog comment
///
Expand Down Expand Up @@ -112,6 +112,13 @@ private static string GuardNativeExit(string invocation) =>

private static string BuildShutdownInvocation(string opFlag, int delay, bool force, string? message)
{
// Windows implicitly enables /f whenever /t is positive.
if (delay > 0 && !force)
throw new InvalidOperationException(
"Power Management: delaySeconds must be 0 when force is false. " +
"Windows forces applications closed with a positive shutdown delay. " +
"Use a separate Delay step before this activity to wait without forcing applications closed.");

// shutdown.exe flags are bare tokens in PowerShell — the PS parser does not try to
// resolve them as cmdlets because of the `&` call operator. Only the user-supplied
// message needs PowerShellQuoter because it can contain apostrophes.
Expand Down
2 changes: 2 additions & 0 deletions src/nodepilot-docs-ui/content/de/activities-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ Das Timeout begrenzt das Warten auf den laufenden Prozess. Nach seinem Ende wird
- **Config:** `action` (shutdown/restart/logoff/abort/hibernate), `delaySeconds`, `force`, `message`
- **Outputs:** —

Bei Shutdown und Restart ist `force` standardmäßig `true`. Mit `force=false` muss `delaySeconds` auf `0` stehen; ein positiver Wert wird vor der Ausführung abgelehnt. [Windows erzwingt bei einem positiven Shutdown-Timer das Schließen von Apps](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/shutdown). Zum Warten ohne erzwungenes Schließen eine separate `delay`-Aktivität vor diesen Schritt setzen und `delaySeconds=0` beibehalten.

## `scheduledTask`

**Remote.** Erfordert meist Admin auf dem Ziel.
Expand Down
2 changes: 2 additions & 0 deletions src/nodepilot-docs-ui/content/en/activities-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ The timeout bounds the wait on the running process. After it exits, reading cont
- **Config:** `action` (shutdown/restart/logoff/abort/hibernate), `delaySeconds`, `force`, `message`
- **Outputs:** —

For shutdown and restart, `force` defaults to `true`. With `force=false`, `delaySeconds` must be `0`; a positive value is rejected before execution. [Windows implicitly forces apps closed when its shutdown timer is positive](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/shutdown). To wait without forcing apps closed, add a separate `delay` activity before this step and keep `delaySeconds=0`.

## `scheduledTask`

**Remote.** Usually requires admin rights on the target.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,14 +745,14 @@ describe('PowerManagementConfig', () => {
it('defaultsToShutdown_showsDelayAndForce', () => {
wrap(<PowerManagementConfig config={{}} onUpdate={vi.fn()} upstreamVars={[]} />);
expect(screen.getByText('Delay (seconds)')).toBeInTheDocument();
expect(screen.getByText(/Force close running apps/i)).toBeInTheDocument();
expect(screen.getByText('Force close running apps')).toBeInTheDocument();
});

it('logoffAction_hidesDelayAndForce', () => {
// logoff, abort and hibernate ignore delay and force, so those fields stay hidden.
wrap(<PowerManagementConfig config={{ action: 'logoff' }} onUpdate={vi.fn()} upstreamVars={[]} />);
expect(screen.queryByText('Delay (seconds)')).not.toBeInTheDocument();
expect(screen.queryByText(/Force close running apps/i)).not.toBeInTheDocument();
expect(screen.queryByText('Force close running apps')).not.toBeInTheDocument();
});

it('actionChange_emitsPatch', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/nodepilot-ui/src/i18n/locales/de/properties.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"forceClose": "Laufende Apps zwangsweise schließen",
"message": "Nachricht (optional)",
"messagePlaceholder": "Geplante Wartung — bitte speichern Sie Ihre Arbeit",
"delayHint": "Bei Targets, auf denen NodePilot selbst läuft, empfiehlt sich ein Delay größer 0, damit der Step sauber zurückkehren kann, bevor das OS herunterfährt.",
"delayHint": "Eine Verzögerung größer 0 erfordert „Laufende Apps zwangsweise schließen“. Zum Warten ohne erzwungenes Schließen einen separaten Delay-Schritt vor diese Aktivität setzen und diese Verzögerung auf 0 stellen.",
"forceCloseHint": "Offene Anwendungen ohne Rückfrage schließen (<code>/f</code>)"
},
"waitForCondition": {
Expand Down
2 changes: 1 addition & 1 deletion src/nodepilot-ui/src/i18n/locales/en/properties.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
"forceClose": "Force close running apps",
"message": "Message (optional)",
"messagePlaceholder": "Scheduled maintenance — please save your work",
"delayHint": "On targets running NodePilot itself, a delay greater than 0 is advisable so the step can return cleanly before the OS shuts down.",
"delayHint": "A delay greater than 0 requires ‘Force close running apps’. To wait without forcing apps closed, add a separate Delay step before this activity and set this delay to 0.",
"forceCloseHint": "Close open applications without asking (<code>/f</code>)"
},
"waitForCondition": {
Expand Down
7 changes: 4 additions & 3 deletions tests/NodePilot.Engine.Tests/Activities/BuildScriptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -537,11 +537,12 @@ public async Task PowerManagement_DelayAndMessage_EmittedAsArgs()
}

[Fact]
public async Task PowerManagement_ForceFalse_OmitsForceFlag()
public async Task PowerManagement_ForceFalseWithDelay_RejectsImplicitForce()
{
var config = ParseConfig("{\"action\":\"shutdown\",\"force\":false,\"delaySeconds\":30}");
var script = await ExecuteAndCaptureScript(CreatePowerMgmt(), config);
ShutdownInvocation(script).Should().Be("& shutdown.exe /s /t 30");
var act = () => ExecuteAndCaptureScript(CreatePowerMgmt(), config);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*delaySeconds must be 0 when force is false*");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ await CreateActivity().ExecuteAsync(Ctx(),

// ---- Error cases ----

[Theory]
[InlineData("shutdown", 1)]
[InlineData("shutdown", 30)]
[InlineData("restart", 1)]
[InlineData("restart", 30)]
[InlineData("Restart", 30)]
public async Task PositiveDelayWithoutForce_RejectsBeforeExecutingCommand(string action, int delaySeconds)
{
var act = () => CreateActivity().ExecuteAsync(Ctx(),
JsonSerializer.SerializeToElement(new { action, delaySeconds, force = false }), CancellationToken.None);

await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*delaySeconds must be 0 when force is false*");
_capturedScript.Should().BeNull();
_sessionFactory.Verify(f => f.CreateSessionAsync(
It.IsAny<ManagedMachine>(), It.IsAny<Credential>(), It.IsAny<CancellationToken>()), Times.Never);
}

// Defence-in-depth: PowerManagement is all-destructive (shutdown/restart/logoff/
// hibernate), so the backend must NOT silently default a missing `action`. The UI
// persists the visual default on first render to heal the common case; this throw
Expand Down Expand Up @@ -193,14 +211,18 @@ await CreateActivity().ExecuteAsync(Ctx(),
_capturedScript.Should().Contain("/f");
}

[Fact]
public async Task Shutdown_ForceFalse_NoSlashF()
[Theory]
[InlineData("shutdown", "/s", 0)]
[InlineData("restart", "/r", 0)]
[InlineData("shutdown", "/s", -5)]
public async Task NonPositiveDelayWithoutForce_UsesZeroTimerAndNoSlashF(string action, string flag, int delaySeconds)
{
_capturedScript = null;
await CreateActivity().ExecuteAsync(Ctx(),
Cfg("{\"action\": \"shutdown\", \"force\": false}"),
JsonSerializer.SerializeToElement(new { action, delaySeconds, force = false }),
CancellationToken.None);
_capturedScript.Should().NotContain("/f");
_capturedScript.Should().Contain($"& shutdown.exe {flag} /t 0");
}

[Fact]
Expand Down
Loading