ever-works-node 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +5139 -3413
- package/package.json +1 -1
- package/packaging/README.md +19 -0
- package/packaging/windows/install-service.logic.ps1 +203 -0
- package/packaging/windows/install-service.ps1 +43 -2
- package/packaging/windows/test-install-service-logic.ps1 +113 -0
- package/packaging/windows/uninstall-service.ps1 +9 -0
package/package.json
CHANGED
package/packaging/README.md
CHANGED
|
@@ -51,6 +51,10 @@ longest expected job.
|
|
|
51
51
|
`systemctl reload` is wired to `ever-works-node pause` — a drain, not a
|
|
52
52
|
restart.
|
|
53
53
|
|
|
54
|
+
Logs go to the journal (`StandardOutput=journal`), which journald already
|
|
55
|
+
rotates and bounds (`SystemMaxUse=` in `journald.conf`), so the unit needs
|
|
56
|
+
nothing of its own.
|
|
57
|
+
|
|
54
58
|
## Windows
|
|
55
59
|
|
|
56
60
|
Enroll first, **as the account the node will run as**, then install from an
|
|
@@ -194,6 +198,21 @@ acceptable there only because `Stop-ScheduledTask` terminates the task
|
|
|
194
198
|
outright and delivers no console event for `cmd.exe` to swallow — that branch
|
|
195
199
|
never drained.
|
|
196
200
|
|
|
201
|
+
Both logs **rotate past 10 MB** (one policy, `Get-NodeLogRotationPolicy`). The
|
|
202
|
+
service rotates them with NSSM's own `AppRotateFiles` / `AppRotateOnline` /
|
|
203
|
+
`AppRotateBytes` — at start and online while it runs. NSSM never deletes a
|
|
204
|
+
rotated file, so the service install also registers `<Name>-LogPrune`, a
|
|
205
|
+
small `SYSTEM` scheduled task that runs daily at 04:30 and at boot and keeps
|
|
206
|
+
the newest five rotated generations of each log (NSSM's `node-<timestamp>.log`
|
|
207
|
+
names and this installer's own); `uninstall-service.ps1` removes it with the
|
|
208
|
+
service. The task cannot rotate while the node runs (`cmd.exe` holds
|
|
209
|
+
the file open without delete sharing, and truncating under its write offset
|
|
210
|
+
would leave a hole), so it rotates when the task STARTS — at boot and on every
|
|
211
|
+
restart-on-failure — as a first action that runs to completion before the
|
|
212
|
+
node action, renaming a full log to `<name>.<yyyyMMddTHHmmss>` and keeping
|
|
213
|
+
the newest five. Re-run the installer to add rotation to a machine
|
|
214
|
+
installed before it existed.
|
|
215
|
+
|
|
197
216
|
Without `-ServicePassword` the task falls back to an **S4U** logon, which is
|
|
198
217
|
minted without the account's password and therefore never unlocks its DPAPI
|
|
199
218
|
master key: Windows Credential Manager reads fail there exactly as they do
|
|
@@ -462,6 +462,35 @@ function Test-PathUnder {
|
|
|
462
462
|
secret; the step is FLAGGED RequiresPassword and the plan stays safe to
|
|
463
463
|
print and to assert on.
|
|
464
464
|
#>
|
|
465
|
+
<#
|
|
466
|
+
.SYNOPSIS
|
|
467
|
+
How big node.log / node.err.log may grow before they are rotated, and
|
|
468
|
+
how many rotated generations the scheduled-task branch keeps.
|
|
469
|
+
|
|
470
|
+
.DESCRIPTION
|
|
471
|
+
Self-build slice AP. Both mechanisms used to append to the same two
|
|
472
|
+
files forever: the NSSM service had no AppRotate* settings at all, and
|
|
473
|
+
the scheduled task redirected through cmd.exe with nothing ever
|
|
474
|
+
trimming the target. On a node that runs for months that is the log
|
|
475
|
+
eating the very disk the workspace floor guards.
|
|
476
|
+
|
|
477
|
+
One policy, read by both branches, so the two cannot drift: 10 MB per
|
|
478
|
+
file. The service rotates with NSSM's own AppRotateFiles /
|
|
479
|
+
AppRotateOnline / AppRotateBytes (online, while running); NSSM never
|
|
480
|
+
deletes a rotated file, so the service branch also registers a daily
|
|
481
|
+
prune task that keeps the newest Keep generations (New-LogPruneScript).
|
|
482
|
+
The scheduled task rotates when it STARTS (boot, and every
|
|
483
|
+
restart-on-failure) and keeps the newest Keep generations - see
|
|
484
|
+
New-ScheduledTaskRotationActionSpec for why it cannot rotate while
|
|
485
|
+
running.
|
|
486
|
+
#>
|
|
487
|
+
function Get-NodeLogRotationPolicy {
|
|
488
|
+
return [pscustomobject]@{
|
|
489
|
+
Bytes = 10485760
|
|
490
|
+
Keep = 5
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
465
494
|
function New-ServiceConfigurationPlan {
|
|
466
495
|
param(
|
|
467
496
|
[Parameter(Mandatory = $true)][string] $Name,
|
|
@@ -503,6 +532,14 @@ function New-ServiceConfigurationPlan {
|
|
|
503
532
|
|
|
504
533
|
& $addNssm @('set', $Name, 'AppStdout', (Join-Path $StateDir 'node.log')) 'redirect stdout'
|
|
505
534
|
& $addNssm @('set', $Name, 'AppStderr', (Join-Path $StateDir 'node.err.log')) 'redirect stderr'
|
|
535
|
+
# Self-build slice AP: rotate both logs - when the service starts, and
|
|
536
|
+
# ONLINE while it runs - past the shared size policy. Re-applied on every
|
|
537
|
+
# run like every other setting, so re-running the installer converges a
|
|
538
|
+
# service installed before rotation existed.
|
|
539
|
+
$rotation = Get-NodeLogRotationPolicy
|
|
540
|
+
& $addNssm @('set', $Name, 'AppRotateFiles', '1') 'rotate the logs when the service starts'
|
|
541
|
+
& $addNssm @('set', $Name, 'AppRotateOnline', '1') 'rotate the logs while the service runs'
|
|
542
|
+
& $addNssm @('set', $Name, 'AppRotateBytes', [string]$rotation.Bytes) "rotate a log once it passes $($rotation.Bytes) bytes"
|
|
506
543
|
& $addNssm @('set', $Name, 'AppStopMethodConsole', '900000') 'give the drain 15 minutes'
|
|
507
544
|
& $addNssm @('set', $Name, 'AppExit', 'Default', 'Restart') 'restart on exit'
|
|
508
545
|
& $addNssm @('set', $Name, 'AppRestartDelay', '10000') 'back off 10s between restarts'
|
|
@@ -685,6 +722,172 @@ function New-ScheduledTaskActionSpec {
|
|
|
685
722
|
}
|
|
686
723
|
}
|
|
687
724
|
|
|
725
|
+
<#
|
|
726
|
+
.SYNOPSIS
|
|
727
|
+
The PowerShell that rotates node.log / node.err.log, as text.
|
|
728
|
+
|
|
729
|
+
.DESCRIPTION
|
|
730
|
+
Self-build slice AP. A log at or past RotateBytes is renamed to
|
|
731
|
+
<name>.<yyyyMMddTHHmmss> - a timestamp, like NSSM's own rotated names -
|
|
732
|
+
and only the newest Keep rotated generations of that log are kept. A missing log, a locked log or any
|
|
733
|
+
other failure is swallowed and the script exits 0: a rotation problem
|
|
734
|
+
must never stop the node from starting.
|
|
735
|
+
|
|
736
|
+
Paths are embedded as single-quoted literals with every quote doubled,
|
|
737
|
+
which is PowerShell's only escape inside single quotes - no expansion of
|
|
738
|
+
$, backtick or anything else can happen to them.
|
|
739
|
+
#>
|
|
740
|
+
function New-LogRotationScript {
|
|
741
|
+
param(
|
|
742
|
+
[Parameter(Mandatory = $true)][string[]] $LogPaths,
|
|
743
|
+
[Parameter(Mandatory = $true)][long] $RotateBytes,
|
|
744
|
+
[Parameter(Mandatory = $true)][int] $Keep
|
|
745
|
+
)
|
|
746
|
+
$quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
|
|
747
|
+
$lines = @(
|
|
748
|
+
'$ErrorActionPreference = ''Stop''',
|
|
749
|
+
"foreach (`$log in @($quoted)) {",
|
|
750
|
+
' try {',
|
|
751
|
+
' $item = Get-Item -LiteralPath $log -ErrorAction SilentlyContinue',
|
|
752
|
+
" if (`$null -eq `$item -or `$item.Length -lt $RotateBytes) { continue }",
|
|
753
|
+
' $stamp = (Get-Date).ToString(''yyyyMMddTHHmmss'')',
|
|
754
|
+
' Move-Item -LiteralPath $log -Destination ($log + ''.'' + $stamp) -Force'
|
|
755
|
+
) + (Get-LogPruneScriptLines -Keep $Keep) + @(
|
|
756
|
+
' }',
|
|
757
|
+
' catch { }',
|
|
758
|
+
'}',
|
|
759
|
+
'exit 0'
|
|
760
|
+
)
|
|
761
|
+
return ($lines -join "`r`n")
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
<#
|
|
765
|
+
.SYNOPSIS
|
|
766
|
+
The lines that delete all but the newest Keep rotated generations of
|
|
767
|
+
the log in $log, as script text for the generated scripts.
|
|
768
|
+
|
|
769
|
+
.DESCRIPTION
|
|
770
|
+
Self-build slice AP (review). Matches BOTH naming schemes a rotated log
|
|
771
|
+
can have in the state directory, and nothing else:
|
|
772
|
+
|
|
773
|
+
* <leaf>.<yyyyMMddTHHmmss> - this installer's task branch
|
|
774
|
+
(node.log.20261008T034500);
|
|
775
|
+
* <base>-<yyyyMMddTHHmmss>[.fff]<ext> - NSSM's own AppRotate names
|
|
776
|
+
(node-20261008T034500.123.log,
|
|
777
|
+
node.err-20261008T034500.123.log).
|
|
778
|
+
|
|
779
|
+
Ordered by the timestamp IN THE NAME, newest first, not by
|
|
780
|
+
LastWriteTime: a rename keeps the file's old modification time, so the
|
|
781
|
+
generation just rotated would otherwise sort as the oldest and be the
|
|
782
|
+
one deleted. The live log itself never matches either pattern.
|
|
783
|
+
#>
|
|
784
|
+
function Get-LogPruneScriptLines {
|
|
785
|
+
param([Parameter(Mandatory = $true)][int] $Keep)
|
|
786
|
+
return @(
|
|
787
|
+
' $leaf = Split-Path -Leaf $log',
|
|
788
|
+
' $base = [IO.Path]::GetFileNameWithoutExtension($leaf)',
|
|
789
|
+
' $ext = [IO.Path]::GetExtension($leaf)',
|
|
790
|
+
' $pattern = ''^(?:'' + [regex]::Escape($leaf) + ''\.\d{8}T\d{6}|'' + [regex]::Escape($base) + ''-\d{8}T\d{6}(?:\.\d{3})?'' + [regex]::Escape($ext) + '')$''',
|
|
791
|
+
' $rotated = @(Get-ChildItem -LiteralPath (Split-Path -Parent $log) -File | Where-Object { $_.Name -match $pattern } | Sort-Object { [regex]::Match($_.Name, ''\d{8}T\d{6}'').Value } -Descending)',
|
|
792
|
+
" if (`$rotated.Count -gt $Keep) { `$rotated | Select-Object -Skip $Keep | Remove-Item -Force }"
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
<#
|
|
797
|
+
.SYNOPSIS
|
|
798
|
+
The PowerShell that ONLY prunes old rotated generations of the logs
|
|
799
|
+
(it never rotates the live file), as text.
|
|
800
|
+
|
|
801
|
+
.DESCRIPTION
|
|
802
|
+
Self-build slice AP (review). NSSM's AppRotate* settings rotate the
|
|
803
|
+
service's logs but never delete a rotated generation, so on a node that
|
|
804
|
+
runs for months they accumulate without bound - the disk the workspace
|
|
805
|
+
floor guards. The service branch therefore also registers a small
|
|
806
|
+
scheduled task (New-ScheduledTaskPruneActionSpec) that runs this daily
|
|
807
|
+
and at boot, keeping the newest Keep generations of each log, the same
|
|
808
|
+
number the scheduled-task branch keeps. Every failure is swallowed and
|
|
809
|
+
the script exits 0.
|
|
810
|
+
#>
|
|
811
|
+
function New-LogPruneScript {
|
|
812
|
+
param(
|
|
813
|
+
[Parameter(Mandatory = $true)][string[]] $LogPaths,
|
|
814
|
+
[Parameter(Mandatory = $true)][int] $Keep
|
|
815
|
+
)
|
|
816
|
+
$quoted = @($LogPaths | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
|
|
817
|
+
$lines = @(
|
|
818
|
+
'$ErrorActionPreference = ''Stop''',
|
|
819
|
+
"foreach (`$log in @($quoted)) {",
|
|
820
|
+
' try {'
|
|
821
|
+
) + (Get-LogPruneScriptLines -Keep $Keep) + @(
|
|
822
|
+
' }',
|
|
823
|
+
' catch { }',
|
|
824
|
+
'}',
|
|
825
|
+
'exit 0'
|
|
826
|
+
)
|
|
827
|
+
return ($lines -join "`r`n")
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
<#
|
|
831
|
+
.SYNOPSIS
|
|
832
|
+
The service branch's log-prune task action (self-build slice AP review).
|
|
833
|
+
|
|
834
|
+
.DESCRIPTION
|
|
835
|
+
Same encoding as New-ScheduledTaskRotationActionSpec: -EncodedCommand,
|
|
836
|
+
so no path or quote in the script survives a second round of
|
|
837
|
+
command-line parsing.
|
|
838
|
+
#>
|
|
839
|
+
function New-ScheduledTaskPruneActionSpec {
|
|
840
|
+
param(
|
|
841
|
+
[Parameter(Mandatory = $true)][string] $PowerShellExe,
|
|
842
|
+
[Parameter(Mandatory = $true)][string[]] $LogPaths,
|
|
843
|
+
[Parameter(Mandatory = $true)][int] $Keep
|
|
844
|
+
)
|
|
845
|
+
$script = New-LogPruneScript -LogPaths $LogPaths -Keep $Keep
|
|
846
|
+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
|
|
847
|
+
return [pscustomobject]@{
|
|
848
|
+
Execute = $PowerShellExe
|
|
849
|
+
Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
|
|
850
|
+
Script = $script
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
<#
|
|
855
|
+
.SYNOPSIS
|
|
856
|
+
The scheduled task's FIRST action: rotate the logs, then let the node
|
|
857
|
+
action start.
|
|
858
|
+
|
|
859
|
+
.DESCRIPTION
|
|
860
|
+
Self-build slice AP. The task branch writes through cmd.exe's >> (see
|
|
861
|
+
New-ScheduledTaskActionSpec), and a file cmd.exe holds open can be
|
|
862
|
+
neither renamed (no FILE_SHARE_DELETE) nor safely truncated (cmd keeps
|
|
863
|
+
its own write offset and would leave a hole of NULs). So this branch
|
|
864
|
+
rotates when the task STARTS - at boot and on every restart-on-failure -
|
|
865
|
+
as a separate action that runs to completion before the node action:
|
|
866
|
+
the Task Scheduler runs a task's actions one after another, in order.
|
|
867
|
+
Between starts the log still grows; NSSM's online rotation is one more
|
|
868
|
+
reason the service stays the recommended shape.
|
|
869
|
+
|
|
870
|
+
-EncodedCommand (base64 of the UTF-16LE script) rather than -Command so
|
|
871
|
+
no path, quote or $ in the script has to survive a second round of
|
|
872
|
+
command-line parsing. Windows PowerShell 5.1 is assumed and is in-box on
|
|
873
|
+
every supported Windows.
|
|
874
|
+
#>
|
|
875
|
+
function New-ScheduledTaskRotationActionSpec {
|
|
876
|
+
param(
|
|
877
|
+
[Parameter(Mandatory = $true)][string] $PowerShellExe,
|
|
878
|
+
[Parameter(Mandatory = $true)][string[]] $LogPaths,
|
|
879
|
+
[Parameter(Mandatory = $true)][long] $RotateBytes,
|
|
880
|
+
[Parameter(Mandatory = $true)][int] $Keep
|
|
881
|
+
)
|
|
882
|
+
$script = New-LogRotationScript -LogPaths $LogPaths -RotateBytes $RotateBytes -Keep $Keep
|
|
883
|
+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($script))
|
|
884
|
+
return [pscustomobject]@{
|
|
885
|
+
Execute = $PowerShellExe
|
|
886
|
+
Argument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"
|
|
887
|
+
Script = $script
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
688
891
|
# --- Preflight ------------------------------------------------------------
|
|
689
892
|
|
|
690
893
|
function New-PreflightFinding {
|
|
@@ -825,6 +825,35 @@ if ($mechanism -eq 'service') {
|
|
|
825
825
|
throw "$reason The service '$Name' was created by this run and has been removed again, so this machine is as it was found. A wrong -ServicePassword shows up here as SCM error 1069."
|
|
826
826
|
}
|
|
827
827
|
|
|
828
|
+
# Self-build slice AP (review): NSSM rotates the logs but never deletes a
|
|
829
|
+
# rotated generation. A small SYSTEM task prunes them, daily and at boot,
|
|
830
|
+
# to the same number the scheduled-task branch keeps. Registered with
|
|
831
|
+
# -Force so a re-run converges it. A failure here WARNS rather than
|
|
832
|
+
# undoing the service: rotation still bounds each file, and an operator
|
|
833
|
+
# can re-run the installer once the cause is fixed.
|
|
834
|
+
$pruneTaskName = "$Name-LogPrune"
|
|
835
|
+
$pruneKeep = (Get-NodeLogRotationPolicy).Keep
|
|
836
|
+
$pruneSpec = New-ScheduledTaskPruneActionSpec `
|
|
837
|
+
-PowerShellExe (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') `
|
|
838
|
+
-LogPaths @($stdoutLog, $stderrLog) `
|
|
839
|
+
-Keep $pruneKeep
|
|
840
|
+
if ($commitChanges) {
|
|
841
|
+
try {
|
|
842
|
+
$pruneAction = New-ScheduledTaskAction -Execute $pruneSpec.Execute -Argument $pruneSpec.Argument -WorkingDirectory $stateDir
|
|
843
|
+
$pruneTriggers = @((New-ScheduledTaskTrigger -Daily -At '04:30'), (New-ScheduledTaskTrigger -AtStartup))
|
|
844
|
+
$prunePrincipal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
|
845
|
+
$pruneSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
|
|
846
|
+
Register-ScheduledTask -TaskName $pruneTaskName -Action $pruneAction -Trigger $pruneTriggers -Principal $prunePrincipal -Settings $pruneSettings -Force | Out-Null
|
|
847
|
+
Write-Host "Registered scheduled task '$pruneTaskName' (keeps the newest $pruneKeep rotated generations of each log)."
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
Write-Warning "Could not register the log-prune task '$pruneTaskName': $($_.Exception.Message). NSSM still rotates the logs, but rotated generations will accumulate until the installer is re-run."
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
Write-Host " Register-ScheduledTask -TaskName $pruneTaskName -Action <powershell -EncodedCommand: keep the newest $pruneKeep rotated logs> -Trigger <daily 04:30, atStartup> -Principal SYSTEM -Force"
|
|
855
|
+
}
|
|
856
|
+
|
|
828
857
|
Write-Host " Application: $nodeExe"
|
|
829
858
|
Write-Host " AppParameters: $argumentLine"
|
|
830
859
|
Write-Host " RunAs: $($identity.ServiceLogonName)"
|
|
@@ -854,7 +883,18 @@ else {
|
|
|
854
883
|
-StdoutPath $stdoutLog `
|
|
855
884
|
-StderrPath $stderrLog
|
|
856
885
|
|
|
857
|
-
|
|
886
|
+
# Self-build slice AP: the logs rotate first, as their own action - see
|
|
887
|
+
# New-ScheduledTaskRotationActionSpec for why this branch cannot rotate
|
|
888
|
+
# while the node runs.
|
|
889
|
+
$rotationPolicy = Get-NodeLogRotationPolicy
|
|
890
|
+
$rotationSpec = New-ScheduledTaskRotationActionSpec `
|
|
891
|
+
-PowerShellExe (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') `
|
|
892
|
+
-LogPaths @($stdoutLog, $stderrLog) `
|
|
893
|
+
-RotateBytes $rotationPolicy.Bytes `
|
|
894
|
+
-Keep $rotationPolicy.Keep
|
|
895
|
+
$rotationAction = New-ScheduledTaskAction -Execute $rotationSpec.Execute -Argument $rotationSpec.Argument -WorkingDirectory $stateDir
|
|
896
|
+
$nodeAction = New-ScheduledTaskAction -Execute $actionSpec.Execute -Argument $actionSpec.Argument -WorkingDirectory $stateDir
|
|
897
|
+
$action = @($rotationAction, $nodeAction)
|
|
858
898
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
|
859
899
|
$settings = New-ScheduledTaskSettingsSet `
|
|
860
900
|
-AllowStartIfOnBatteries `
|
|
@@ -924,6 +964,7 @@ else {
|
|
|
924
964
|
Write-Host " Start-ScheduledTask -TaskName $Name"
|
|
925
965
|
}
|
|
926
966
|
|
|
967
|
+
Write-Host " Rotate: $($rotationSpec.Execute) -EncodedCommand <logs past $($rotationPolicy.Bytes) bytes, keep $($rotationPolicy.Keep)>"
|
|
927
968
|
Write-Host " Execute: $($actionSpec.Execute)"
|
|
928
969
|
Write-Host " Argument: $($actionSpec.Argument)"
|
|
929
970
|
Write-Host " RunAs: $($identity.TaskUserId) (LogonType $($taskPlan.LogonType))"
|
|
@@ -931,7 +972,7 @@ else {
|
|
|
931
972
|
}
|
|
932
973
|
|
|
933
974
|
Write-Host ''
|
|
934
|
-
Write-Host "Logs: $stdoutLog and $stderrLog"
|
|
975
|
+
Write-Host "Logs: $stdoutLog and $stderrLog (rotated past $((Get-NodeLogRotationPolicy).Bytes) bytes)"
|
|
935
976
|
Write-Host 'Operating:'
|
|
936
977
|
Write-Host ' drain (finish in-flight work, take no more): ever-works-node pause'
|
|
937
978
|
Write-Host ' take work again: ever-works-node resume'
|
|
@@ -452,6 +452,23 @@ foreach ($required in @('Application', 'AppParameters', 'AppDirectory', 'Start',
|
|
|
452
452
|
# command-line argument, which process-creation telemetry records.
|
|
453
453
|
Assert-False ($planSettings -contains 'ObjectName') 'the password must not reach nssm as an ObjectName argument'
|
|
454
454
|
|
|
455
|
+
# Self-build slice AP: both logs rotate - at start AND online - past the
|
|
456
|
+
# shared policy, on a fresh install and on a re-run over an old service.
|
|
457
|
+
$rotationPolicy = Get-NodeLogRotationPolicy
|
|
458
|
+
Assert-Equal $rotationPolicy.Bytes 10485760 'the rotation threshold is 10 MB'
|
|
459
|
+
foreach ($case in @(
|
|
460
|
+
@{ Label = 'fresh install'; Plan = $freshPlan },
|
|
461
|
+
@{ Label = 're-run over an existing service'; Plan = $existingPlan }
|
|
462
|
+
)) {
|
|
463
|
+
$rotateSettings = @{}
|
|
464
|
+
foreach ($step in @($case.Plan | Where-Object { $_.Kind -eq 'nssm' -and $_.Arguments[0] -eq 'set' -and $_.Arguments[2] -like 'AppRotate*' })) {
|
|
465
|
+
$rotateSettings[$step.Arguments[2]] = $step.Arguments[3]
|
|
466
|
+
}
|
|
467
|
+
Assert-Equal $rotateSettings['AppRotateFiles'] '1' "$($case.Label) must rotate the logs at service start"
|
|
468
|
+
Assert-Equal $rotateSettings['AppRotateOnline'] '1' "$($case.Label) must rotate the logs while the service runs"
|
|
469
|
+
Assert-Equal $rotateSettings['AppRotateBytes'] '10485760' "$($case.Label) must rotate past the shared size policy"
|
|
470
|
+
}
|
|
471
|
+
|
|
455
472
|
# --- Redaction ------------------------------------------------------------
|
|
456
473
|
|
|
457
474
|
$renderedSecret = Format-ServicePlanStep -NssmPath 'C:\nssm.exe' -Step $identityStep
|
|
@@ -538,6 +555,102 @@ $spacedLogSpec = New-ScheduledTaskActionSpec `
|
|
|
538
555
|
-StderrPath 'C:\log dir\node.err.log'
|
|
539
556
|
Assert-True ($spacedLogSpec.Argument -like '*>> "C:\log dir\node.log"*') 'a spaced log path must be quoted for cmd.exe'
|
|
540
557
|
|
|
558
|
+
# --- Scheduled-task log rotation (self-build slice AP) ---------------------
|
|
559
|
+
|
|
560
|
+
$rotationSpec = New-ScheduledTaskRotationActionSpec `
|
|
561
|
+
-PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
|
|
562
|
+
-LogPaths @('C:\ProgramData\ever-works-node\node.log', "C:\it's here\node.err.log") `
|
|
563
|
+
-RotateBytes 10485760 `
|
|
564
|
+
-Keep 5
|
|
565
|
+
Assert-Equal $rotationSpec.Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' 'the rotation action runs Windows PowerShell'
|
|
566
|
+
Assert-True ($rotationSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the rotation script travels encoded, never as a re-parsed command line'
|
|
567
|
+
$encodedScript = $rotationSpec.Argument.Substring($rotationSpec.Argument.LastIndexOf(' ') + 1)
|
|
568
|
+
$decodedScript = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedScript))
|
|
569
|
+
Assert-Equal $decodedScript $rotationSpec.Script 'the encoded command must decode to exactly the reviewed script'
|
|
570
|
+
Assert-True ($decodedScript.Contains("'C:\ProgramData\ever-works-node\node.log'")) 'stdout log must be rotated'
|
|
571
|
+
Assert-True ($decodedScript.Contains("'C:\it''s here\node.err.log'")) 'a quote in a log path must be doubled inside its single-quoted literal'
|
|
572
|
+
Assert-True ($decodedScript.Contains('-lt 10485760')) 'the threshold must be the policy bytes'
|
|
573
|
+
Assert-True ($decodedScript.TrimEnd().EndsWith('exit 0')) 'a rotation problem must never stop the node action'
|
|
574
|
+
$parseErrors = $null
|
|
575
|
+
[void][Management.Automation.Language.Parser]::ParseInput($decodedScript, [ref]$null, [ref]$parseErrors)
|
|
576
|
+
Assert-Equal @($parseErrors).Count 0 'the generated rotation script must parse under Windows PowerShell'
|
|
577
|
+
|
|
578
|
+
# Run the generated script for real, against a scratch directory under TEMP
|
|
579
|
+
# (the only thing in this file that touches a disk, and it removes it).
|
|
580
|
+
$rotationDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-rotation-test-' + [Guid]::NewGuid().ToString('N'))
|
|
581
|
+
New-Item -ItemType Directory -Path $rotationDir | Out-Null
|
|
582
|
+
try {
|
|
583
|
+
$bigLog = Join-Path $rotationDir 'node.log'
|
|
584
|
+
$smallLog = Join-Path $rotationDir 'node.err.log'
|
|
585
|
+
Set-Content -LiteralPath $bigLog -Value ('x' * 64) -Encoding Ascii
|
|
586
|
+
Set-Content -LiteralPath $smallLog -Value 'x' -Encoding Ascii
|
|
587
|
+
foreach ($stamp in @('20260101T000000', '20260102T000000', '20260103T000000')) {
|
|
588
|
+
Set-Content -LiteralPath (Join-Path $rotationDir "node.log.$stamp") -Value 'old' -Encoding Ascii
|
|
589
|
+
}
|
|
590
|
+
Set-Content -LiteralPath (Join-Path $rotationDir 'node.log.keep-me') -Value 'not a rotation' -Encoding Ascii
|
|
591
|
+
$smallScript = New-LogRotationScript -LogPaths @($bigLog, $smallLog, (Join-Path $rotationDir 'missing.log')) -RotateBytes 32 -Keep 2
|
|
592
|
+
$psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
593
|
+
$encodedSmall = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallScript))
|
|
594
|
+
& $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedSmall
|
|
595
|
+
Assert-Equal $LASTEXITCODE 0 'the rotation script must exit 0'
|
|
596
|
+
Assert-False (Test-Path -LiteralPath $bigLog) 'a log past the threshold must be rotated away'
|
|
597
|
+
Assert-True (Test-Path -LiteralPath $smallLog) 'a log under the threshold must be left alone'
|
|
598
|
+
$rotatedNames = @(Get-ChildItem -LiteralPath $rotationDir -File | Where-Object { $_.Name -match '^node\.log\.\d{8}T\d{6}$' } | ForEach-Object { $_.Name })
|
|
599
|
+
Assert-Equal $rotatedNames.Count 2 'only the newest Keep rotated generations survive'
|
|
600
|
+
Assert-False ($rotatedNames -contains 'node.log.20260101T000000') 'the oldest generation is the one dropped'
|
|
601
|
+
Assert-True (Test-Path -LiteralPath (Join-Path $rotationDir 'node.log.keep-me')) 'a file that is not a rotation must never be touched'
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
Remove-Item -LiteralPath $rotationDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
# --- NSSM log retention (self-build slice AP, review) ----------------------
|
|
608
|
+
|
|
609
|
+
# NSSM rotates but never deletes a rotated generation, so the service branch
|
|
610
|
+
# registers a prune task. Its script must keep the newest Keep generations of
|
|
611
|
+
# EACH log under BOTH naming schemes, and touch nothing else.
|
|
612
|
+
$pruneSpec = New-ScheduledTaskPruneActionSpec `
|
|
613
|
+
-PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
|
|
614
|
+
-LogPaths @('C:\ProgramData\ever-works-node\node.log', 'C:\ProgramData\ever-works-node\node.err.log') `
|
|
615
|
+
-Keep 5
|
|
616
|
+
Assert-True ($pruneSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the prune script travels encoded'
|
|
617
|
+
$decodedPrune = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($pruneSpec.Argument.Substring($pruneSpec.Argument.LastIndexOf(' ') + 1)))
|
|
618
|
+
Assert-Equal $decodedPrune $pruneSpec.Script 'the encoded prune command must decode to exactly the reviewed script'
|
|
619
|
+
Assert-False ($decodedPrune.Contains('Move-Item')) 'the prune script must never rotate the live log - NSSM owns that'
|
|
620
|
+
Assert-True ($decodedPrune.TrimEnd().EndsWith('exit 0')) 'a prune problem must never surface as a task failure'
|
|
621
|
+
$pruneParseErrors = $null
|
|
622
|
+
[void][Management.Automation.Language.Parser]::ParseInput($decodedPrune, [ref]$null, [ref]$pruneParseErrors)
|
|
623
|
+
Assert-Equal @($pruneParseErrors).Count 0 'the generated prune script must parse under Windows PowerShell'
|
|
624
|
+
|
|
625
|
+
$pruneDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-prune-test-' + [Guid]::NewGuid().ToString('N'))
|
|
626
|
+
New-Item -ItemType Directory -Path $pruneDir | Out-Null
|
|
627
|
+
try {
|
|
628
|
+
$liveLog = Join-Path $pruneDir 'node.log'
|
|
629
|
+
$liveErr = Join-Path $pruneDir 'node.err.log'
|
|
630
|
+
Set-Content -LiteralPath $liveLog -Value 'live' -Encoding Ascii
|
|
631
|
+
Set-Content -LiteralPath $liveErr -Value 'live' -Encoding Ascii
|
|
632
|
+
# NSSM's own names, written NEWEST FIRST so modification time disagrees
|
|
633
|
+
# with the timestamp in the name: the name must win.
|
|
634
|
+
foreach ($stamp in @('20260104T000000.400', '20260103T000000.300', '20260102T000000.200', '20260101T000000.100')) {
|
|
635
|
+
Set-Content -LiteralPath (Join-Path $pruneDir "node-$stamp.log") -Value 'old' -Encoding Ascii
|
|
636
|
+
Set-Content -LiteralPath (Join-Path $pruneDir "node.err-$stamp.log") -Value 'old' -Encoding Ascii
|
|
637
|
+
}
|
|
638
|
+
Set-Content -LiteralPath (Join-Path $pruneDir 'node-notes.log') -Value 'not a rotation' -Encoding Ascii
|
|
639
|
+
$smallPrune = New-LogPruneScript -LogPaths @($liveLog, $liveErr) -Keep 2
|
|
640
|
+
$psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
641
|
+
& $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallPrune)))
|
|
642
|
+
Assert-Equal $LASTEXITCODE 0 'the prune script must exit 0'
|
|
643
|
+
$left = @(Get-ChildItem -LiteralPath $pruneDir -File | ForEach-Object { $_.Name } | Sort-Object)
|
|
644
|
+
Assert-True ($left -contains 'node.log') 'the live stdout log is never pruned'
|
|
645
|
+
Assert-True ($left -contains 'node.err.log') 'the live stderr log is never pruned'
|
|
646
|
+
Assert-True ($left -contains 'node-notes.log') 'a file that is not a rotation must never be touched'
|
|
647
|
+
Assert-Equal (@($left | Where-Object { $_ -match '^node-\d{8}T' }) -join ',') 'node-20260103T000000.300.log,node-20260104T000000.400.log' 'NSSM stdout generations: the newest two BY NAME survive'
|
|
648
|
+
Assert-Equal (@($left | Where-Object { $_ -match '^node\.err-\d{8}T' }) -join ',') 'node.err-20260103T000000.300.log,node.err-20260104T000000.400.log' 'NSSM stderr generations: the newest two survive, counted per log'
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
Remove-Item -LiteralPath $pruneDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
652
|
+
}
|
|
653
|
+
|
|
541
654
|
# --- Preflight ------------------------------------------------------------
|
|
542
655
|
|
|
543
656
|
Assert-Equal (Invoke-Preflight).Count 0 'a fully healthy service install must produce no findings at all'
|
|
@@ -58,6 +58,15 @@ if ($null -ne $task) {
|
|
|
58
58
|
$removed = $true
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
# Self-build slice AP (review): the service branch's log-prune task. It is
|
|
62
|
+
# this installer's own artefact and means nothing without the service.
|
|
63
|
+
$pruneTaskName = "$Name-LogPrune"
|
|
64
|
+
$pruneTask = Get-ScheduledTask -TaskName $pruneTaskName -ErrorAction SilentlyContinue
|
|
65
|
+
if ($null -ne $pruneTask) {
|
|
66
|
+
Unregister-ScheduledTask -TaskName $pruneTaskName -Confirm:$false
|
|
67
|
+
Write-Host "Removed scheduled task '$pruneTaskName'."
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
if (-not $removed) {
|
|
62
71
|
Write-Host "Nothing named '$Name' is registered on this machine."
|
|
63
72
|
}
|