feat: Windows security-only updates and agent-initiated reboot
This commit is contained in:
@@ -3,6 +3,7 @@ package updates
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,10 +15,6 @@ const (
|
||||
// routinely slow. Ten minutes is not generous, it is realistic.
|
||||
searchTimeout = 10 * time.Minute
|
||||
|
||||
// A patch-Tuesday cumulative genuinely takes this long to download and
|
||||
// install on a modest server.
|
||||
applyTimeout = 60 * time.Minute
|
||||
|
||||
rebootTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
@@ -39,37 +36,6 @@ foreach ($u in $result.Updates) {
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
const applyScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
|
||||
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||
foreach ($u in $result.Updates) {
|
||||
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
|
||||
if (-not $u.EulaAccepted) {
|
||||
try { $u.AcceptEula() } catch { continue }
|
||||
}
|
||||
$null = $batch.Add($u)
|
||||
}
|
||||
|
||||
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
|
||||
|
||||
$downloader = $session.CreateUpdateDownloader()
|
||||
$downloader.Updates = $batch
|
||||
$null = $downloader.Download()
|
||||
|
||||
$installer = $session.CreateUpdateInstaller()
|
||||
$installer.Updates = $batch
|
||||
$r = $installer.Install()
|
||||
|
||||
Write-Output ('resultcode=' + $r.ResultCode)
|
||||
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
|
||||
# process must exit non-zero so the agent logs a failure rather than an ack.
|
||||
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
|
||||
exit 0
|
||||
`
|
||||
|
||||
const rebootScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$si = New-Object -ComObject Microsoft.Update.SystemInfo
|
||||
@@ -95,14 +61,23 @@ func checkAvailable() ([]PackageUpdate, error) {
|
||||
return parseUpdateSearch(out)
|
||||
}
|
||||
|
||||
func applyAll() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
|
||||
func apply(securityOnly bool, deadline time.Time) (Result, error) {
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
|
||||
if _, err := winexec.Run(ctx, applyScript); err != nil {
|
||||
return fmt.Errorf("windows update install: %w", err)
|
||||
out, err := winexec.Run(ctx, applyScriptFor(securityOnly))
|
||||
tail := newTailBuffer(outputTailMax)
|
||||
_, _ = tail.Write([]byte(out))
|
||||
if err != nil {
|
||||
return Result{Output: tail.String()}, fmt.Errorf("windows update install: %w", err)
|
||||
}
|
||||
return nil
|
||||
return Result{Output: tail.String()}, nil
|
||||
}
|
||||
|
||||
// scheduleReboot gives the host sixty seconds, so the PatchResult announcing
|
||||
// the reboot is sent before the service stops.
|
||||
func scheduleReboot() error {
|
||||
return exec.Command("shutdown", "/r", "/t", "60", "/c", "Vantage patch policy").Run()
|
||||
}
|
||||
|
||||
func rebootRequired() bool {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package updates
|
||||
|
||||
// applyScriptFor builds the Windows Update install script. It lives in a file
|
||||
// with no build tag so its content is tested on Linux: this module has no
|
||||
// Windows CI.
|
||||
//
|
||||
// Security-only keeps updates in the Security Updates or Critical Updates
|
||||
// classifications. Those two GUIDs are fixed by Microsoft and identical on
|
||||
// every Windows Update and WSUS server.
|
||||
func applyScriptFor(securityOnly bool) string {
|
||||
flag := "$false"
|
||||
if securityOnly {
|
||||
flag = "$true"
|
||||
}
|
||||
return "$SecurityOnly = " + flag + "\n" + applyScriptBody
|
||||
}
|
||||
|
||||
const applyScriptBody = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$securityCats = @('0FA1201D-4330-4FA8-8AE9-B877473B6441', 'E6CF1350-C01B-414D-A61F-263D14D133B4')
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
|
||||
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||
foreach ($u in $result.Updates) {
|
||||
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
|
||||
if ($SecurityOnly) {
|
||||
$isSec = $false
|
||||
foreach ($c in $u.Categories) { if ($securityCats -contains $c.CategoryID.ToUpper()) { $isSec = $true } }
|
||||
if (-not $isSec) { continue }
|
||||
}
|
||||
if (-not $u.EulaAccepted) {
|
||||
try { $u.AcceptEula() } catch { continue }
|
||||
}
|
||||
Write-Output ('selected: ' + $u.Title)
|
||||
$null = $batch.Add($u)
|
||||
}
|
||||
|
||||
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
|
||||
|
||||
$downloader = $session.CreateUpdateDownloader()
|
||||
$downloader.Updates = $batch
|
||||
$null = $downloader.Download()
|
||||
|
||||
$installer = $session.CreateUpdateInstaller()
|
||||
$installer.Updates = $batch
|
||||
$r = $installer.Install()
|
||||
|
||||
Write-Output ('resultcode=' + $r.ResultCode)
|
||||
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
|
||||
# process must exit non-zero so the agent reports a failure rather than an ack.
|
||||
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
|
||||
exit 0
|
||||
`
|
||||
@@ -0,0 +1,29 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
catSecurity = "0FA1201D-4330-4FA8-8AE9-B877473B6441"
|
||||
catCritical = "E6CF1350-C01B-414D-A61F-263D14D133B4"
|
||||
)
|
||||
|
||||
func TestApplyScriptSecurityOnly(t *testing.T) {
|
||||
s := applyScriptFor(true)
|
||||
if !strings.HasPrefix(strings.TrimSpace(s), "$SecurityOnly = $true") {
|
||||
t.Fatalf("script must open with the flag set:\n%s", s)
|
||||
}
|
||||
for _, id := range []string{catSecurity, catCritical} {
|
||||
if !strings.Contains(s, id) {
|
||||
t.Errorf("script missing category %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyScriptAll(t *testing.T) {
|
||||
if !strings.HasPrefix(strings.TrimSpace(applyScriptFor(false)), "$SecurityOnly = $false") {
|
||||
t.Fatal("script must open with the flag cleared")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user