diff --git a/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml b/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml index 89b5e9f738..772433ba3a 100644 --- a/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml +++ b/eng/pipelines/common/templates/steps/configure-sql-server-macos-step.yml @@ -29,21 +29,33 @@ steps: export PS4='+ [$(date "+%Y-%m-%d %H:%M:%S")] ' set -x - # Install Docker CLI (not Desktop — Colima provides the daemon) and SQLCMD tools. + # Install Colima (which provides the Docker daemon, since Docker Desktop is + # not available here), the docker CLI, and sqlcmd. brew install colima - brew install docker - brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release - brew update - # Homebrew 5.2+ requires explicit trust for third-party taps. Run this - # after 'brew update' so the trust command is available even if the runner - # image shipped an older Homebrew version. - brew trust microsoft/mssql-release - HOMEBREW_ACCEPT_EULA=Y brew install mssql-tools18 - - # Fail fast if sqlcmd was not installed (e.g. tap-trust or formula error). - # Without this check the script would loop for ~6 minutes trying to connect. + + # Homebrew ships no Intel macOS bottle for the current docker formula, so + # 'brew install docker' compiles the CLI (and builds Go to do it), which + # takes minutes and often exhausts the step timeout. Install the newest + # bottled version directly instead. + DOCKER_CLI_DIR="$HOME/.docker-cli/bin" + if ! pwsh -NoProfile -File "$(Build.SourcesDirectory)/eng/pipelines/scripts/Install-DockerCli.macos.ps1" -DestinationPath "$DOCKER_CLI_DIR"; then + echo "ERROR: Failed to install the docker CLI." + exit 1 + fi + # prependpath only affects later steps, so fix PATH for this one as well. + export PATH="$DOCKER_CLI_DIR:$PATH" + + # go-sqlcmd, rather than mssql-tools18 from the microsoft/mssql-release + # tap. That tap pins an openssl@3 formula that has no Intel bottle, so + # installing it compiled openssl from source and cost 12.5 minutes of the + # step budget. go-sqlcmd is a single bottled Go binary with no openssl + # dependency, and accepts the same flags used below. + brew install sqlcmd + + # Fail fast if sqlcmd was not installed. Without this check the script + # would loop for ~6 minutes trying to connect. if ! command -v sqlcmd &>/dev/null; then - echo "ERROR: sqlcmd is not on PATH after brew install. Check the mssql-tools18 installation above." + echo "ERROR: sqlcmd is not on PATH after 'brew install sqlcmd'." exit 1 fi @@ -82,8 +94,8 @@ steps: fi # Point the docker CLI at Colima's daemon socket. Colima normally sets an - # active docker context, but the standalone docker CLI (installed above via - # 'brew install docker') can default to unix:///var/run/docker.sock, which + # active docker context, but the standalone docker CLI installed above can + # default to unix:///var/run/docker.sock, which # does not exist on macOS without Docker Desktop. This caused every # 'docker pull' to fail instantly with: # failed to connect to the docker API at unix:///var/run/docker.sock @@ -231,3 +243,9 @@ steps: fi displayName: 'Configure SQL Server [macOS]' + # Well above a healthy run, but low enough that a wedged install or Colima + # boot fails here instead of consuming the whole test job. Measured worst + # case is ~24 minutes: Colima boot ~6, the SQL image pull ~10 (7 of which is + # extraction inside the VM), and up to 6 more waiting for SQL to accept + # connections. + timeoutInMinutes: 40 diff --git a/eng/pipelines/scripts/Install-DockerCli.macos.ps1 b/eng/pipelines/scripts/Install-DockerCli.macos.ps1 new file mode 100644 index 0000000000..bdaf588e5c --- /dev/null +++ b/eng/pipelines/scripts/Install-DockerCli.macos.ps1 @@ -0,0 +1,225 @@ +<# +.SYNOPSIS + Installs the docker CLI on an Intel macOS agent from a Homebrew bottle. + +.DESCRIPTION + Homebrew ships no Intel macOS bottle for the current docker formula, so + 'brew install docker' compiles the CLI - and builds Go in order to do it - + which takes minutes and routinely exhausts the pipeline step timeout. + + This installs the newest docker version that *is* bottled for Intel macOS, + read from Homebrew's own OCI registry on ghcr.io. Bottles are + content-addressed, so the download is verified against the digest the + registry advertises rather than a checksum pinned here that someone has to + remember to bump. Nothing is downloaded from outside Homebrew, and the + result is exactly what 'brew install' would have produced. + + Only the docker CLI is handled this way. colima and lima still install + through brew: their bottles carry payloads outside bin/ and colima depends + on lima at runtime, so neither can be installed by lifting a single binary. + +.PARAMETER DestinationPath + Directory the docker binary is written to. Prepended to PATH for subsequent + pipeline steps. + +.NOTES + Intel macOS only. On any other architecture this fails rather than + installing a binary the agent cannot run. +#> +param( + [string]$DestinationPath = (Join-Path -Path $HOME -ChildPath '.docker-cli/bin') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# tar's exit code is checked explicitly below so the failure carries the member +# path rather than PowerShell's generic native-command error. +$PSNativeCommandUseErrorActionPreference = $false + +$Registry = 'https://ghcr.io' +$RegistryRepository = 'homebrew/core/docker' + +# Newest first. A bottle built for an older macOS still runs on a newer one, so +# any of these work on the current agents. +$PreferredCodenames = @('sequoia', 'sonoma', 'ventura') + +#region Helper Functions + +function Get-RegistryToken { + <# + .SYNOPSIS + An anonymous pull token for the formula's registry repository. + #> + + $uri = "$Registry/token?service=ghcr.io&scope=repository:${RegistryRepository}:pull" + return (Invoke-RestMethod -Uri $uri -MaximumRetryCount 3 -RetryIntervalSec 5).token +} + +function Get-BottleVersion { + <# + .SYNOPSIS + Every docker version tag in the registry, newest first. + #> + param( + [Parameter(Mandatory)][string]$Token + ) + + $uri = "$Registry/v2/$RegistryRepository/tags/list?n=1000" + $tags = @() + + while ($uri) { + $response = Invoke-RestMethod ` + -Uri $uri ` + -Headers @{ Authorization = "Bearer $Token" } ` + -MaximumRetryCount 3 -RetryIntervalSec 5 ` + -ResponseHeadersVariable 'responseHeaders' + + $tags += $response.tags + + # ghcr.io orders tags lexically, which puts the newest on the last page. + $link = if ($responseHeaders.ContainsKey('Link')) { @($responseHeaders['Link'])[0] } else { $null } + $uri = if ($link -match '<([^>]+)>') { $Registry + $Matches[1] } else { $null } + } + + return $tags | + Where-Object { $_ -match '^\d+\.\d+\.\d+(-\d+)?$' } | + Sort-Object -Property { [version]($_ -replace '-', '.') } -Descending +} + +function Get-BottleRefName { + <# + .SYNOPSIS + The bottle ref name an OCI index entry advertises, or $null. + #> + param( + [Parameter(Mandatory)]$IndexEntry + ) + + $annotations = $IndexEntry.PSObject.Properties['annotations'] + if (-not $annotations) { return $null } + + $refName = $annotations.Value.PSObject.Properties['org.opencontainers.image.ref.name'] + if (-not $refName) { return $null } + + return $refName.Value +} + +function Find-Bottle { + <# + .SYNOPSIS + The newest given version carrying an Intel macOS bottle, with that + bottle's manifest digest. + #> + param( + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][string[]]$Version + ) + + $headers = @{ + Authorization = "Bearer $Token" + Accept = 'application/vnd.oci.image.index.v1+json' + } + + foreach ($candidate in $Version) { + $index = Invoke-RestMethod ` + -Uri "$Registry/v2/$RegistryRepository/manifests/$candidate" ` + -Headers $headers ` + -MaximumRetryCount 3 -RetryIntervalSec 5 + + foreach ($codename in $PreferredCodenames) { + foreach ($entry in $index.manifests) { + # The index already belongs to this version, so only the + # platform component needs matching. Splitting on '.' is what + # keeps 'sonoma' from also matching 'arm64_sonoma', and rejects + # the linux and ':all' refs outright. + $ref = Get-BottleRefName -IndexEntry $entry + if ($ref -and ($ref.Split('.') -contains $codename)) { + return [pscustomobject]@{ + Version = $candidate + Digest = $entry.digest + Ref = $ref + } + } + } + } + } + + throw "No Intel macOS docker bottle in any of the $($Version.Count) published versions." +} + +function Save-BottleBlob { + <# + .SYNOPSIS + Downloads a bottle archive and verifies it against its layer digest. + #> + param( + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][string]$ManifestDigest, + [Parameter(Mandatory)][string]$Path + ) + + $manifest = Invoke-RestMethod ` + -Uri "$Registry/v2/$RegistryRepository/manifests/$ManifestDigest" ` + -Headers @{ + Authorization = "Bearer $Token" + Accept = 'application/vnd.oci.image.manifest.v1+json' + } ` + -MaximumRetryCount 3 -RetryIntervalSec 5 + + $layerDigest = $manifest.layers[0].digest + + Invoke-WebRequest ` + -Uri "$Registry/v2/$RegistryRepository/blobs/$layerDigest" ` + -Headers @{ Authorization = "Bearer $Token" } ` + -MaximumRetryCount 3 -RetryIntervalSec 5 ` + -OutFile $Path + + $expected = $layerDigest -replace '^sha256:', '' + $actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash + + # -ne is case-insensitive, so Get-FileHash's uppercase output compares equal. + if ($actual -ne $expected) { + throw "Bottle digest mismatch: expected $expected, got $actual." + } +} + +#endregion Helper Functions + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +if ($architecture -ne 'X64') { + throw "This installs an Intel macOS bottle, but the agent architecture is $architecture." +} + +$token = Get-RegistryToken +# Every version scanned costs a registry round-trip (~0.2s), but the whole list +# is walked rather than a fixed window: Homebrew has stopped publishing Intel +# bottles, so the newest usable version sinks further down the list over time. +$versions = @(Get-BottleVersion -Token $token) +$bottle = Find-Bottle -Token $token -Version $versions + +$archive = Join-Path ([System.IO.Path]::GetTempPath()) "docker-bottle-$([guid]::NewGuid().ToString('n')).tar.gz" + +try { + Save-BottleBlob -Token $token -ManifestDigest $bottle.Digest -Path $archive + + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + + # Homebrew keeps the cellar directory at the plain version even for a + # revision build, so the '29.7.2-1' bottle unpacks from 'docker/29.7.2'. + $cellarVersion = $bottle.Version -replace '-\d+$', '' + $member = "docker/$cellarVersion/bin/docker" + + # Naming the one member we want is what keeps the rest of the archive - an + # anonymous download - from ever being written to disk. + tar -xz -f $archive -C $DestinationPath --strip-components 3 $member + if ($LASTEXITCODE -ne 0) { + throw "Extracting $member from the docker bottle failed (tar exit $LASTEXITCODE)." + } +} +finally { + Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue +} + +Write-Host "Installed docker $($bottle.Version) ($($bottle.Ref)) to $DestinationPath" +Write-Host "##vso[task.prependpath]$DestinationPath" diff --git a/eng/pipelines/scripts/tests/Install-DockerCli.macos.Tests.ps1 b/eng/pipelines/scripts/tests/Install-DockerCli.macos.Tests.ps1 new file mode 100644 index 0000000000..08fdcb8076 --- /dev/null +++ b/eng/pipelines/scripts/tests/Install-DockerCli.macos.Tests.ps1 @@ -0,0 +1,194 @@ +<# +.SYNOPSIS + Pester tests for Install-DockerCli.macos.ps1. + +.DESCRIPTION + These cover the bottle selection rules, which are the part that fails by + silently installing the wrong artifact rather than by crashing: picking the + newest version that is actually bottled for Intel, never taking an arm64, + linux or ':all' bottle, and reading a revision build out of the unrevised + cellar directory. + + 'Invoke-RestMethod', 'Invoke-WebRequest' and 'tar' are mocked, so the tests + need no network and no macOS. +#> + +BeforeAll { + $global:scriptPath = Join-Path $PSScriptRoot '..' 'Install-DockerCli.macos.ps1' + + # A stand-in for the bottle archive. The script verifies what it downloads + # against the digest the registry advertises, so the tests have to advertise + # this content's real hash. + $global:blobBytes = [System.Text.Encoding]::UTF8.GetBytes('not-really-a-bottle') + $blobHash = ( + [System.Security.Cryptography.SHA256]::HashData($global:blobBytes) | + ForEach-Object { $_.ToString('x2') } + ) -join '' + $global:blobDigest = "sha256:$blobHash" + + function New-Index { + <# + .SYNOPSIS + An OCI image index annotated with the given bottle ref names. + #> + param([string[]]$RefName) + + return [pscustomobject]@{ + manifests = @( + $RefName | ForEach-Object { + [pscustomobject]@{ + digest = "sha256:digest-$_" + annotations = [pscustomobject]@{ 'org.opencontainers.image.ref.name' = $_ } + } + } + ) + } + } + + function Get-TarMember { + <# + .SYNOPSIS + The archive member the script asked tar to extract. + #> + return @($global:tarArgs)[-1] + } +} + +Describe 'Install-DockerCli.macos.ps1' -Skip:([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { + + BeforeAll { + Mock -CommandName 'Invoke-RestMethod' -MockWith { + if ($Uri -like '*/token?*') { + return [pscustomobject]@{ token = 'test-token' } + } + + if ($Uri -like '*/tags/list*') { + if ($ResponseHeadersVariable) { + Set-Variable -Name $ResponseHeadersVariable -Value @{} -Scope Global + } + return [pscustomobject]@{ tags = $global:tags } + } + + if ($Uri -like '*/manifests/sha256:*') { + $global:manifestRequests += $Uri + return [pscustomobject]@{ + layers = @([pscustomobject]@{ digest = $global:advertisedDigest }) + } + } + + $version = $Uri -replace '.*/manifests/', '' + if (-not $global:refsByVersion.ContainsKey($version)) { + throw "Unexpected manifest request for '$version'." + } + return New-Index -RefName $global:refsByVersion[$version] + } + + Mock -CommandName 'Invoke-WebRequest' -MockWith { + [System.IO.File]::WriteAllBytes($OutFile, $global:blobBytes) + } + + Mock -CommandName 'tar' -MockWith { + $global:tarArgs = $args + $global:LASTEXITCODE = 0 + } + } + + BeforeEach { + $global:tags = @('29.7.2') + $global:refsByVersion = @{ '29.7.2' = @('29.7.2.sonoma') } + $global:advertisedDigest = $global:blobDigest + $global:manifestRequests = @() + $global:tarArgs = @() + + $global:destination = Join-Path ([System.IO.Path]::GetTempPath()) "docker-cli-test-$([guid]::NewGuid().ToString('n'))" + } + + AfterEach { + if (Test-Path -LiteralPath $global:destination) { + Remove-Item -LiteralPath $global:destination -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context 'Version selection' { + + It 'Takes the newest version that is bottled for Intel' { + $global:tags = @('29.7.2', '29.8.0') + $global:refsByVersion = @{ + '29.8.0' = @('29.8.0.arm64_sequoia', '29.8.0.x86_64_linux') + '29.7.2' = @('29.7.2.sonoma') + } + + & $global:scriptPath -DestinationPath $global:destination + + Get-TarMember | Should -Be 'docker/29.7.2/bin/docker' + } + + It 'Ignores tags that are not versions' { + $global:tags = @('latest', '29.7', '29.7.2-beta', '29.7.2') + + & $global:scriptPath -DestinationPath $global:destination + + Get-TarMember | Should -Be 'docker/29.7.2/bin/docker' + } + + It 'Keeps scanning past a long run of arm64-only versions' { + # Homebrew has stopped publishing Intel bottles, so the newest usable + # version sinks further down the list with every docker release. A + # fixed scan window would eventually stop reaching it. + $global:tags = @('29.7.2') + (0..19 | ForEach-Object { "30.$_.0" }) + $global:refsByVersion = @{ '29.7.2' = @('29.7.2.sonoma') } + foreach ($i in 0..19) { + $global:refsByVersion["30.$i.0"] = @("30.$i.0.arm64_sequoia", "30.$i.0.x86_64_linux") + } + + & $global:scriptPath -DestinationPath $global:destination + + Get-TarMember | Should -Be 'docker/29.7.2/bin/docker' + } + + It 'Ranks a revision build above its base version' { + $global:tags = @('29.7.2', '29.7.2-1') + $global:refsByVersion = @{ + '29.7.2-1' = @('29.7.2.sonoma.1') + '29.7.2' = @('29.7.2.sonoma') + } + + & $global:scriptPath -DestinationPath $global:destination + + # Homebrew keeps the cellar directory at the plain version, so the + # revision must not leak into the member path. + Get-TarMember | Should -Be 'docker/29.7.2/bin/docker' + $global:manifestRequests | Should -Contain 'https://ghcr.io/v2/homebrew/core/docker/manifests/sha256:digest-29.7.2.sonoma.1' + } + } + + Context 'Platform selection' { + + It 'Prefers the newest macOS codename that is bottled' { + $global:refsByVersion = @{ '29.7.2' = @('29.7.2.ventura', '29.7.2.sonoma') } + + & $global:scriptPath -DestinationPath $global:destination + + $global:manifestRequests | Should -Contain 'https://ghcr.io/v2/homebrew/core/docker/manifests/sha256:digest-29.7.2.sonoma' + } + + It 'Never selects an arm64, linux or :all bottle' { + $global:refsByVersion = @{ + '29.7.2' = @('29.7.2.arm64_sonoma', '29.7.2.arm64_linux', '29.7.2.x86_64_linux', '29.7.2.all') + } + + { & $global:scriptPath -DestinationPath $global:destination } | + Should -Throw '*No Intel macOS docker bottle*' + } + } + + Context 'Download integrity' { + + It 'Fails when the bottle does not match the advertised digest' { + $global:advertisedDigest = 'sha256:' + ('0' * 64) + + { & $global:scriptPath -DestinationPath $global:destination } | + Should -Throw '*digest mismatch*' + } + } +} diff --git a/eng/pipelines/scripts/tests/README.md b/eng/pipelines/scripts/tests/README.md index 8906214afd..70534a9ed1 100644 --- a/eng/pipelines/scripts/tests/README.md +++ b/eng/pipelines/scripts/tests/README.md @@ -24,6 +24,7 @@ Add `-Output Detailed` to see per-test results. | File | Covers | | ---- | ------ | | `Open-LocalizationPr.Tests.ps1` | `Open-LocalizationPr.ps1` — de-duplication of the scheduled localization pull request. | +| `Install-DockerCli.macos.Tests.ps1` | `Install-DockerCli.macos.ps1` — Homebrew bottle selection for the macOS docker CLI. | -`git` and `Invoke-RestMethod` are mocked, so the tests never touch the network -or a real repository. +`git`, `tar`, `Invoke-RestMethod` and `Invoke-WebRequest` are mocked, so the +tests never touch the network or a real repository.