Skip to content

Commit c8b183c

Browse files
authored
Make Octopus Server upgrades download reliably and fail safely (#1712)
* Add failing Octopus Server upgrade tests * Implement reliable Octopus Server upgrades
1 parent 914cf66 commit c8b183c

2 files changed

Lines changed: 269 additions & 5 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
$ErrorActionPreference = "Stop";
2+
Set-StrictMode -Version "Latest";
3+
4+
$scriptPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "upgrade-octopus-server.ScriptBody.ps1")
5+
$scriptBody = [System.IO.File]::ReadAllText($scriptPath)
6+
$scriptBodyWithoutEntryPoint = $scriptBody -replace '(?m)^StartInstallationOfOctopusServer\s*$', ''
7+
8+
# These stubs make the revised internal seams mockable while the tests are red against master.
9+
function NewOctopusTempFile { [System.IO.Path]::GetTempFileName() }
10+
function InvokeOctopusDownloadRequest { throw "The ranged downloader is not implemented." }
11+
12+
. ([scriptblock]::Create($scriptBodyWithoutEntryPoint))
13+
14+
function New-FakeDownloadResponse {
15+
param(
16+
[System.Net.HttpStatusCode] $StatusCode,
17+
[long] $ContentLength,
18+
[string] $ContentRange,
19+
[byte[]] $Content = @()
20+
)
21+
22+
$response = [pscustomobject]@{
23+
StatusCode = $StatusCode
24+
ContentLength = $ContentLength
25+
Headers = @{ "Content-Range" = $ContentRange }
26+
Stream = New-Object System.IO.MemoryStream -ArgumentList @(,$Content)
27+
}
28+
Add-Member -InputObject $response -MemberType ScriptMethod -Name GetResponseStream -Value { $this.Stream }
29+
Add-Member -InputObject $response -MemberType ScriptMethod -Name Dispose -Value { $this.Stream.Dispose() }
30+
return $response
31+
}
32+
33+
function msiexec {}
34+
35+
Describe "Upgrade Octopus Server ranged download" {
36+
BeforeEach {
37+
$script:DownloadedFile = $null
38+
$script:Requests = @()
39+
40+
Mock NewOctopusTempFile {
41+
$script:DownloadedFile = [System.IO.Path]::GetTempFileName()
42+
return $script:DownloadedFile
43+
}
44+
Mock New-Object { throw "The legacy WebClient downloader was invoked." } `
45+
-ParameterFilter { $TypeName -eq "System.Net.WebClient" }
46+
Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = "Valid" } }
47+
Mock Write-Host {}
48+
Mock Start-Sleep {}
49+
}
50+
51+
AfterEach {
52+
if ($script:DownloadedFile -and (Test-Path -LiteralPath $script:DownloadedFile)) {
53+
Remove-Item -LiteralPath $script:DownloadedFile -Force
54+
}
55+
}
56+
57+
It "downloads consecutive ranges and returns one validated path" {
58+
Mock InvokeOctopusDownloadRequest {
59+
$script:Requests += "$Method`:$RangeStart-$RangeEnd"
60+
if ($Method -eq "HEAD") {
61+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 7
62+
}
63+
64+
$length = [int]($RangeEnd - $RangeStart + 1)
65+
return New-FakeDownloadResponse -StatusCode PartialContent `
66+
-ContentLength $length `
67+
-ContentRange "bytes $RangeStart-$RangeEnd/7" `
68+
-Content ([byte[]](1..$length))
69+
}
70+
71+
$result = @(DownloadOctopusServer "2026.3.15954" 3 3)
72+
73+
$result.Count | Should Be 1
74+
$result[0] | Should Be $script:DownloadedFile
75+
(Get-Item -LiteralPath $result[0]).Length | Should Be 7
76+
$script:Requests -join "," | Should Be "HEAD:-,GET:0-2,GET:3-5,GET:6-6"
77+
Assert-MockCalled Get-AuthenticodeSignature -Times 1
78+
Assert-MockCalled Write-Host -Times 1 -ParameterFilter { $Object -eq "Download completed" }
79+
}
80+
81+
It "retries a failed range from the last completed byte" {
82+
$script:RangeAttempts = 0
83+
Mock InvokeOctopusDownloadRequest {
84+
if ($Method -eq "HEAD") {
85+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 4
86+
}
87+
88+
$script:RangeAttempts++
89+
$script:Requests += "$RangeStart-$RangeEnd"
90+
if ($script:RangeAttempts -eq 1) { throw "connection reset" }
91+
return New-FakeDownloadResponse -StatusCode PartialContent `
92+
-ContentLength 4 -ContentRange "bytes 0-3/4" -Content ([byte[]](1, 2, 3, 4))
93+
}
94+
Mock Write-Warning {}
95+
96+
$result = DownloadOctopusServer "2026.3.15954" 4 3
97+
98+
$result | Should Be $script:DownloadedFile
99+
$script:Requests -join "," | Should Be "0-3,0-3"
100+
Assert-MockCalled Write-Warning -Times 1
101+
}
102+
103+
It "removes the partial file after exhausting the retry limit" {
104+
Mock InvokeOctopusDownloadRequest {
105+
if ($Method -eq "HEAD") {
106+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 4
107+
}
108+
throw "connection reset"
109+
}
110+
111+
{ DownloadOctopusServer "2026.3.15954" 4 3 } | Should Throw "connection reset"
112+
Test-Path -LiteralPath $script:DownloadedFile | Should Be $false
113+
Assert-MockCalled InvokeOctopusDownloadRequest -Times 3 -ParameterFilter { $Method -eq "GET" }
114+
}
115+
116+
It "rejects an invalid range response and removes the file" {
117+
Mock InvokeOctopusDownloadRequest {
118+
if ($Method -eq "HEAD") {
119+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 3
120+
}
121+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 3 `
122+
-ContentRange $null -Content ([byte[]](1, 2, 3))
123+
}
124+
125+
{ DownloadOctopusServer "2026.3.15954" 3 1 } | Should Throw "invalid response"
126+
Test-Path -LiteralPath $script:DownloadedFile | Should Be $false
127+
}
128+
129+
It "rejects a range with an unexpected byte count" {
130+
Mock InvokeOctopusDownloadRequest {
131+
if ($Method -eq "HEAD") {
132+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 3
133+
}
134+
return New-FakeDownloadResponse -StatusCode PartialContent -ContentLength 2 `
135+
-ContentRange "bytes 0-2/3" -Content ([byte[]](1, 2))
136+
}
137+
138+
{ DownloadOctopusServer "2026.3.15954" 3 1 } | Should Throw "unexpected file length"
139+
Test-Path -LiteralPath $script:DownloadedFile | Should Be $false
140+
}
141+
142+
It "rejects an installer without a valid Authenticode signature" {
143+
Mock InvokeOctopusDownloadRequest {
144+
if ($Method -eq "HEAD") {
145+
return New-FakeDownloadResponse -StatusCode OK -ContentLength 3
146+
}
147+
return New-FakeDownloadResponse -StatusCode PartialContent -ContentLength 3 `
148+
-ContentRange "bytes 0-2/3" -Content ([byte[]](1, 2, 3))
149+
}
150+
Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = "NotSigned" } }
151+
152+
{ DownloadOctopusServer "2026.3.15954" 3 1 } | Should Throw "Authenticode"
153+
Test-Path -LiteralPath $script:DownloadedFile | Should Be $false
154+
}
155+
156+
It "does not stop or install when the download terminates" {
157+
Mock DownloadOctopusServer { throw "download failed" }
158+
Mock StopOctopusServer {}
159+
Mock InstallOctopusServer {}
160+
Mock StartOctopusServer {}
161+
162+
{ InstallSetVersionOnOctopusServer "2025.4.10250" "2026.3.15954" } | Should Throw "download failed"
163+
164+
Assert-MockCalled StopOctopusServer -Times 0
165+
Assert-MockCalled InstallOctopusServer -Times 0
166+
Assert-MockCalled StartOctopusServer -Times 0
167+
}
168+
}
169+
170+
Describe "Upgrade Octopus Server installation safety" {
171+
It "waits for msiexec with one quoted MSI path" {
172+
Mock msiexec {}
173+
Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } }
174+
175+
InstallOctopusServer "C:\Temp\Octopus Server.msi"
176+
177+
Assert-MockCalled msiexec -Times 0
178+
Assert-MockCalled Start-Process -Times 1 -ParameterFilter {
179+
$expectedArguments = @("/i", '"C:\Temp\Octopus Server.msi"', "/quiet", "/norestart")
180+
$argumentDifference = Compare-Object $ArgumentList $expectedArguments -SyncWindow 0
181+
($FilePath -eq "msiexec.exe") -and $Wait -and $PassThru -and ($null -eq $argumentDifference)
182+
}
183+
}
184+
185+
It "terminates with the msiexec exit code when installation fails" {
186+
Mock msiexec {}
187+
Mock Start-Process { [pscustomobject]@{ ExitCode = 1603 } }
188+
189+
{ InstallOctopusServer "C:\Temp\Octopus.msi" } | Should Throw "1603"
190+
}
191+
192+
It "stops, installs, starts, and cleans up in order on success" {
193+
$script:Operations = @()
194+
Mock DownloadOctopusServer { "C:\Temp\Octopus.msi" }
195+
Mock StopOctopusServer { $script:Operations += "stop" }
196+
Mock InstallOctopusServer { $script:Operations += "install" }
197+
Mock StartOctopusServer { $script:Operations += "start" }
198+
Mock RemoveTempFile { $script:Operations += "cleanup" }
199+
200+
InstallSetVersionOnOctopusServer "2025.4.10250" "2026.3.15954"
201+
202+
$script:Operations -join "," | Should Be "stop,install,start,cleanup"
203+
}
204+
205+
It "starts the server and cleans up when installation fails" {
206+
$script:Operations = @()
207+
Mock DownloadOctopusServer { "C:\Temp\Octopus.msi" }
208+
Mock StopOctopusServer { $script:Operations += "stop" }
209+
Mock InstallOctopusServer { $script:Operations += "install"; throw "installation failed" }
210+
Mock StartOctopusServer { $script:Operations += "start" }
211+
Mock RemoveTempFile { $script:Operations += "cleanup" }
212+
213+
{ InstallSetVersionOnOctopusServer "2025.4.10250" "2026.3.15954" } | Should Throw "installation failed"
214+
215+
$script:Operations -join "," | Should Be "stop,install,start,cleanup"
216+
}
217+
218+
It "cleans up without installing or starting when server stop fails" {
219+
$script:Operations = @()
220+
Mock DownloadOctopusServer { "C:\Temp\Octopus.msi" }
221+
Mock StopOctopusServer { $script:Operations += "stop"; throw "stop failed" }
222+
Mock InstallOctopusServer { $script:Operations += "install" }
223+
Mock StartOctopusServer { $script:Operations += "start" }
224+
Mock RemoveTempFile { $script:Operations += "cleanup" }
225+
226+
{ InstallSetVersionOnOctopusServer "2025.4.10250" "2026.3.15954" } | Should Throw "stop failed"
227+
228+
$script:Operations -join "," | Should Be "stop,cleanup"
229+
}
230+
231+
It "preserves the installation failure and reports a recovery failure" {
232+
Mock DownloadOctopusServer { "C:\Temp\Octopus.msi" }
233+
Mock StopOctopusServer {}
234+
Mock InstallOctopusServer { throw "installation failed" }
235+
Mock StartOctopusServer { throw "recovery failed" }
236+
Mock RemoveTempFile {}
237+
Mock Write-Warning {}
238+
239+
{ InstallSetVersionOnOctopusServer "2025.4.10250" "2026.3.15954" } | Should Throw "installation failed"
240+
241+
Assert-MockCalled Write-Warning -Times 1 -ParameterFilter { $Message -like "*recovery failed*" }
242+
Assert-MockCalled RemoveTempFile -Times 1
243+
}
244+
}
245+
246+
Describe "Upgrade Octopus Server template contract" {
247+
BeforeAll {
248+
$templatePath = [System.IO.Path]::Combine($PSScriptRoot, "..", "upgrade-octopus-server.json")
249+
$template = Get-Content -LiteralPath $templatePath -Raw | ConvertFrom-Json
250+
}
251+
252+
It "preserves the template identity and parameters" {
253+
$template.Id | Should Be "4b3a1f09-1827-41bb-88a4-894c6317922b"
254+
@($template.Parameters.Name) -join "," | Should Be "InstanceName,InstallPath,SpecificOctopusServerVersionToInstall"
255+
}
256+
257+
It "publishes version 11 with current modification metadata" {
258+
$template.Version | Should Be 11
259+
$template.'$Meta'.OctopusVersion | Should Be "2026.3.15954"
260+
$template.LastModifiedBy | Should Be "bcullman"
261+
$template.LastModifiedDate | Should Not BeNullOrEmpty
262+
}
263+
}

0 commit comments

Comments
 (0)