@atomiton-dev/gap-cli 0.0.0-stage → 0.9.0-mvp
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/README.md +132 -2
- package/bin/gap.js +47 -0
- package/installer/CHANNEL +1 -0
- package/installer/VERSION +1 -0
- package/installer/docker-compose.yml +86 -0
- package/installer/env.template +13 -0
- package/installer/gap +365 -0
- package/installer/gap-lib.ps1 +1248 -0
- package/installer/gap-lib.sh +1203 -0
- package/installer/gap.ps1 +361 -0
- package/installer/install.ps1 +262 -0
- package/installer/install.sh +252 -0
- package/lib/launcher.js +31 -0
- package/lib/postinstall.js +137 -0
- package/package.json +31 -4
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
# Shared by install.ps1 and gap.ps1 (#1750, D27, D28). Dot-sourced, never run; the caller sets
|
|
2
|
+
# $GapSource to the folder it runs from (the unzipped installer, or ~\.gap once installed).
|
|
3
|
+
#
|
|
4
|
+
# Written for Windows PowerShell 5.1 (the one every Windows 10/11 has): no ??, no ternary, no &&.
|
|
5
|
+
# ASCII only, because 5.1 reads a script that has no BOM as ANSI.
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = 'Continue' # native commands write progress to stderr; check $LASTEXITCODE instead
|
|
8
|
+
|
|
9
|
+
# Plain script variables, not environment variables: a GAP_* variable in the user's shell must not
|
|
10
|
+
# change them.
|
|
11
|
+
# D28: the installed instance (.env, docker-compose.yml, gap.ps1) lives in $GapHome, never in the
|
|
12
|
+
# unzipped folder, so that moving or deleting that folder cannot lose the database password.
|
|
13
|
+
$GapHome = Join-Path $HOME '.gap'
|
|
14
|
+
$LauncherDir = Join-Path $GapHome 'bin'
|
|
15
|
+
$GapProject = 'gap'
|
|
16
|
+
$GapPortFirst = 8061
|
|
17
|
+
$GapPortLast = 8070
|
|
18
|
+
$GapUpTimeout = 300 # D26: seconds to wait for the three services to become healthy
|
|
19
|
+
# Windows PowerShell 5.1 has no $IsWindows; PowerShell 7 does. False only when these scripts are
|
|
20
|
+
# exercised with pwsh on macOS or Linux, where there is no user PATH in a registry to change.
|
|
21
|
+
$OnWindows = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows
|
|
22
|
+
|
|
23
|
+
# Only .env decides version, port and password (A4, E40): drop GAP_* and COMPOSE_* from this process.
|
|
24
|
+
Get-ChildItem Env: | Where-Object { $_.Name -match '^(GAP|COMPOSE)_' } | ForEach-Object { Remove-Item -LiteralPath "Env:$($_.Name)" }
|
|
25
|
+
|
|
26
|
+
# Windows Terminal and PowerShell 7 draw the check mark; the old console font may not.
|
|
27
|
+
$Tick = if ($env:WT_SESSION -or $PSVersionTable.PSVersion.Major -ge 7) { [string][char]0x2713 } else { 'OK' }
|
|
28
|
+
|
|
29
|
+
function Write-Ok($message) { Write-Host "[$Tick] $message" }
|
|
30
|
+
function Write-Warn($message) { Write-Host "[!] $message" }
|
|
31
|
+
|
|
32
|
+
function Get-EnvPath { Join-Path $GapHome '.env' }
|
|
33
|
+
|
|
34
|
+
# docker compose against this folder.
|
|
35
|
+
function Invoke-Dc {
|
|
36
|
+
& docker compose -p $GapProject --project-directory $GapHome -f (Join-Path $GapHome 'docker-compose.yml') --env-file (Get-EnvPath) @args
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# .env is read by Compose: UTF-8 without a BOM (a BOM would glue itself to the first key) and LF.
|
|
40
|
+
function Write-EnvFile($text) {
|
|
41
|
+
[IO.File]::WriteAllText((Get-EnvPath), $text, (New-Object Text.UTF8Encoding($false)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Get-EnvValue($key) {
|
|
45
|
+
foreach ($line in [IO.File]::ReadAllLines((Get-EnvPath))) {
|
|
46
|
+
if ($line.StartsWith("$key=")) { return $line.Substring($key.Length + 1) }
|
|
47
|
+
}
|
|
48
|
+
return ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Get-ImageRef PREFIX NAME TAG: the name Docker knows an image by. PREFIX is the registry part with its trailing
|
|
52
|
+
# slash (the npm channel: ghcr.io/atomiton-gap/) or empty (the ZIP channel: the local name, as `docker load` made
|
|
53
|
+
# it). -f, not "$prefix$name:$tag": PowerShell would read `$name:` as a variable in a scope called name.
|
|
54
|
+
function Get-ImageRef($prefix, $name, $tag) { '{0}{1}:{2}' -f $prefix, $name, $tag }
|
|
55
|
+
|
|
56
|
+
# Get-InstalledPrefix: the PREFIX of the images that are on this machine, as the .env written by the install says
|
|
57
|
+
# (#1986). Empty when .env has no such key (an install by a ZIP of #1750) or has it empty. Not "the prefix of the
|
|
58
|
+
# channel running this script": an npm install over a machine that a ZIP installed rewrites .env. Use it to find
|
|
59
|
+
# what is installed (uninstall); what to pull is the channel's own matter.
|
|
60
|
+
function Get-InstalledPrefix { Get-EnvValue 'GAP_IMAGE_PREFIX' }
|
|
61
|
+
|
|
62
|
+
# --- Which installer channel runs this script (#1986, #2011) ----------------------------------------------
|
|
63
|
+
# The npm package carries a CHANNEL file that says `npm`, next to the scripts (installer\build-npm-package.sh). A ZIP
|
|
64
|
+
# has no such file, and neither has the copy of gap.ps1 in ~\.gap, so both are the `zip` channel.
|
|
65
|
+
$NpmImagePrefix = 'ghcr.io/atomiton-gap/'
|
|
66
|
+
|
|
67
|
+
function Get-Channel {
|
|
68
|
+
$file = Join-Path $GapSource 'CHANNEL'
|
|
69
|
+
if ((Test-Path -LiteralPath $file) -and (([IO.File]::ReadAllText($file)).Trim() -eq 'npm')) { return 'npm' }
|
|
70
|
+
return 'zip'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Get-ChannelPrefix / Get-ChannelPullPolicy: what the channel being run pulls from, and how Compose treats a missing
|
|
74
|
+
# image. Never read from .env: an npm install over a machine that a ZIP installed finds an empty prefix there, and would
|
|
75
|
+
# pull a gap-kernel:<V> that no registry has. Get-InstalledPrefix (above) is for finding what is installed.
|
|
76
|
+
function Get-ChannelPrefix { if ((Get-Channel) -eq 'npm') { $NpmImagePrefix } else { '' } }
|
|
77
|
+
function Get-ChannelPullPolicy { if ((Get-Channel) -eq 'npm') { 'missing' } else { 'never' } }
|
|
78
|
+
|
|
79
|
+
# Get-InstallCommand: what the user types to install again, in the words of this channel.
|
|
80
|
+
# Get-InstallName: the same command as Get-InstallCommand, short, for the sentence "... then run <it> again" of install.ps1's
|
|
81
|
+
# own error messages. The ZIP keeps the bare script name those messages always had; npm has no install.ps1 to name (it
|
|
82
|
+
# sits inside node_modules), only `gap install`.
|
|
83
|
+
function Get-InstallName { if ((Get-Channel) -eq 'npm') { 'gap install' } else { 'install.ps1' } }
|
|
84
|
+
|
|
85
|
+
function Get-InstallCommand {
|
|
86
|
+
if ((Get-Channel) -eq 'npm') { return 'gap install' }
|
|
87
|
+
return "powershell -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'install.ps1')`""
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Get-PullErrorKind TEXT: unauthorized | denied | notfound | network | other, for what `docker pull` printed. The texts
|
|
91
|
+
# are the ones measured on ghcr.io (03/11/2026, spike S6, on macOS: the text comes from the Docker daemon, which is
|
|
92
|
+
# the same on Windows, but this has not been measured there): `error from registry: unauthorized` is no credential at
|
|
93
|
+
# all, `error from registry: denied` is a credential that was refused or has no right to the package (not told apart by
|
|
94
|
+
# text), `<ref>: not found` is a missing tag or package, `failed to do request` is a network that did not answer.
|
|
95
|
+
# Anything else is `other`: its text is shown as Docker gave it.
|
|
96
|
+
function Get-PullErrorKind($text) {
|
|
97
|
+
$t = "$text".ToLower()
|
|
98
|
+
if ($t.Contains('failed to do request')) { return 'network' }
|
|
99
|
+
if ($t.Contains('error from registry: unauthorized')) { return 'unauthorized' }
|
|
100
|
+
if ($t.Contains('error from registry: denied')) { return 'denied' }
|
|
101
|
+
if ($t.Contains(': not found')) { return 'notfound' }
|
|
102
|
+
return 'other'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Write-LoginSteps / Write-DeniedHelp: what to do about `unauthorized` and `denied`. Shared by Write-PullFailure (a pull that
|
|
106
|
+
# failed), doctor (access read with `docker manifest inspect`) and `gap login` (the same check, right after the login). None
|
|
107
|
+
# of them prints or asks for a token here: `gap login` asks for it and hands it to Docker on stdin.
|
|
108
|
+
function Write-LoginSteps {
|
|
109
|
+
Write-Host 'Action:'
|
|
110
|
+
Write-Host ' 1. Create a GitHub personal access token (classic) that has the read:packages permission:'
|
|
111
|
+
Write-Host ' https://github.com/settings/tokens/new?scopes=read:packages'
|
|
112
|
+
Write-Host ' 2. Log in to the registry: gap login'
|
|
113
|
+
Write-Host ' and enter your GitHub username and the token when it asks.'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# $script:LoginJustDone is set by `gap login` before it checks the images: the token was accepted a second ago, so an expired
|
|
117
|
+
# or revoked token is not what the refusal means, and saying so would send the user to make a second token for nothing.
|
|
118
|
+
$script:LoginJustDone = $false
|
|
119
|
+
|
|
120
|
+
function Write-DeniedHelp {
|
|
121
|
+
if ($script:LoginJustDone) {
|
|
122
|
+
Write-Host "You are logged in, but your GitHub account has not been given access to GAP's images."
|
|
123
|
+
Write-Host 'Ask the GAP administrator to add you to the team that can read them. If you were added just now,'
|
|
124
|
+
Write-Host 'accept the invitation GitHub emailed you, then check again with: gap doctor'
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
Write-Host 'Either your token is no longer valid (expired or revoked), or your GitHub account has not been given'
|
|
128
|
+
Write-Host "access to GAP's images. Ask the GAP administrator to add you to the team that can read them."
|
|
129
|
+
Write-Host 'If you were added, accept the invitation GitHub emailed you. If you are already on the team,'
|
|
130
|
+
Write-Host 'create a new token with read:packages and log in again:'
|
|
131
|
+
Write-Host ' gap login'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Get-LoginErrorKind TEXT: rejected | network | other, for what `docker login` printed when it failed (the same words, and the
|
|
135
|
+
# same order, as classify_login_error in gap-lib.sh). Measured with Docker 29 on ghcr.io (05/11/2026), on macOS: a made-up
|
|
136
|
+
# token ends with `Error response from daemon: Get "https://ghcr.io/v2/": denied: denied`; an unreachable registry with
|
|
137
|
+
# `... Client.Timeout exceeded ...` or `... no such host`. A bare "denied" is not enough for `rejected`: an unreadable Docker
|
|
138
|
+
# config says `permission denied` too, and that is not a bad token.
|
|
139
|
+
function Get-LoginErrorKind($text) {
|
|
140
|
+
$t = "$text".ToLowerInvariant()
|
|
141
|
+
foreach ($marker in 'no such host', 'client.timeout', 'deadline exceeded', 'connection refused', 'i/o timeout', 'dial tcp', 'failed to do request') {
|
|
142
|
+
if ($t.Contains($marker)) { return 'network' }
|
|
143
|
+
}
|
|
144
|
+
if ($t.Contains('": denied') -or $t.Contains('": unauthorized')) { return 'rejected' }
|
|
145
|
+
return 'other'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Write-PullFailure IMAGE KIND TEXT [RETRY]: say what to do about a pull that failed. Nothing was written yet when this runs,
|
|
149
|
+
# which the message says. RETRY is the command to run again when it is not the installer's own: `gap update` names itself, so
|
|
150
|
+
# that the way on is not `gap install`, which would move the version with no backup and no guard.
|
|
151
|
+
function Write-PullFailure($image, $kind, $text, $retry = '') {
|
|
152
|
+
$version = ([IO.File]::ReadAllText((Join-Path $GapSource 'VERSION'))).Trim()
|
|
153
|
+
switch ($kind) {
|
|
154
|
+
'unauthorized' {
|
|
155
|
+
Write-Warn "GAP's images are private and this machine is not logged in to ghcr.io"
|
|
156
|
+
Write-LoginSteps
|
|
157
|
+
}
|
|
158
|
+
'denied' {
|
|
159
|
+
Write-Warn "ghcr.io refused access to $image"
|
|
160
|
+
Write-DeniedHelp
|
|
161
|
+
}
|
|
162
|
+
'notfound' {
|
|
163
|
+
Write-Warn "$image was not found in the registry"
|
|
164
|
+
Write-Host "This GAP package is version $version. That version may not be published yet; check the version you were given."
|
|
165
|
+
}
|
|
166
|
+
'network' {
|
|
167
|
+
Write-Warn "Could not reach ghcr.io while pulling $image"
|
|
168
|
+
Write-Host 'Check your internet connection (and proxy settings). Images and layers already downloaded are kept.'
|
|
169
|
+
}
|
|
170
|
+
default {
|
|
171
|
+
Write-Warn "Pulling $image failed"
|
|
172
|
+
@("$text" -split "`r?`n" | Where-Object { $_ }) | Select-Object -Last 5 | ForEach-Object { Write-Host $_ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if ($retry) {
|
|
176
|
+
Write-Host 'Nothing was changed.'
|
|
177
|
+
Write-Host "Then run: $retry"
|
|
178
|
+
} else {
|
|
179
|
+
Write-Host 'Nothing was installed and no data was changed.'
|
|
180
|
+
Write-Host "Then run: $(Get-InstallCommand)"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Invoke-PullImages VERSION: pull the images this version needs from the registry of the channel (npm), the ones that are
|
|
185
|
+
# not on this machine yet. Returns $false after Write-PullFailure has said what to do. Runs before anything is written to
|
|
186
|
+
# GapHome, so a user who may not pull is left with nothing half installed. Docker's own output is not shown: a failure
|
|
187
|
+
# is explained in our words (the text is shown only for a kind we do not know). RETRY: see Write-PullFailure.
|
|
188
|
+
function Invoke-PullImages($version, $retry = '') {
|
|
189
|
+
$prefix = Get-ChannelPrefix
|
|
190
|
+
foreach ($image in (Get-ImageRef $prefix 'gap-kernel' $version), (Get-ImageRef $prefix 'gap-admin-ui' $version), (Get-ImageRef $prefix 'gap-postgres' '16')) {
|
|
191
|
+
& docker image inspect $image *> $null
|
|
192
|
+
if ($LASTEXITCODE -eq 0) { continue }
|
|
193
|
+
Write-Host "Pulling $image, this can take a few minutes..."
|
|
194
|
+
$out = & docker pull $image 2>&1
|
|
195
|
+
if ($LASTEXITCODE -ne 0) {
|
|
196
|
+
$text = @($out) -join "`n"
|
|
197
|
+
Write-PullFailure $image (Get-PullErrorKind $text) $text $retry
|
|
198
|
+
return $false
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return $true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Set-EnvValue KEY VALUE: set one line of .env, keep the others (and their secrets) untouched. A KEY
|
|
205
|
+
# that is not in the file yet is added at the end (#1986: an .env written before a key existed, such as
|
|
206
|
+
# the GAP_IMAGE_PREFIX of the npm channel, must be able to receive it).
|
|
207
|
+
function Set-EnvValue($key, $value) {
|
|
208
|
+
$found = $false
|
|
209
|
+
# An explicit list, not `$x = foreach (...)`: that gives $null for an empty file and a bare string for
|
|
210
|
+
# a one-line file, and `@($null)` would put an empty first line in front of the new key.
|
|
211
|
+
$out = New-Object 'System.Collections.Generic.List[string]'
|
|
212
|
+
foreach ($line in [IO.File]::ReadAllLines((Get-EnvPath))) {
|
|
213
|
+
if ($line.StartsWith("$key=")) { $found = $true; $out.Add("$key=$value") } else { $out.Add($line) }
|
|
214
|
+
}
|
|
215
|
+
if (-not $found) { $out.Add("$key=$value") }
|
|
216
|
+
Write-EnvFile (($out -join "`n") + "`n")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Get-UiUrl { "http://127.0.0.1:$(Get-EnvValue 'GAP_UI_PORT')" }
|
|
220
|
+
|
|
221
|
+
function Get-OsName {
|
|
222
|
+
$arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLower()
|
|
223
|
+
"Windows $arch"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Prints the failing step, then what to do about it. $rerun = command to run again afterwards;
|
|
227
|
+
# $needsUp = the caller is going to start the stack, which also checks that Compose has
|
|
228
|
+
# `up --wait-timeout`. Returns $true when everything is in place.
|
|
229
|
+
function Test-Prereqs($rerun, $needsUp) {
|
|
230
|
+
$lines = @("[$Tick] OS: $(Get-OsName)")
|
|
231
|
+
$action = ''
|
|
232
|
+
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
|
233
|
+
$lines += '[!] Docker CLI not found'
|
|
234
|
+
$action = 'Install Docker Desktop (https://www.docker.com/products/docker-desktop/), then open a new terminal'
|
|
235
|
+
} else {
|
|
236
|
+
$lines += "[$Tick] Docker CLI detected"
|
|
237
|
+
$osType = & docker info --format '{{.OSType}}' 2>$null
|
|
238
|
+
if ($LASTEXITCODE -ne 0) {
|
|
239
|
+
$lines += '[!] Docker daemon stopped'
|
|
240
|
+
$action = 'Start Docker Desktop and wait until it says it is running'
|
|
241
|
+
} elseif ("$osType".Trim() -ne 'linux') {
|
|
242
|
+
$lines += "[$Tick] Docker daemon running"
|
|
243
|
+
$lines += '[!] Docker is set to Windows containers'
|
|
244
|
+
$action = 'Switch Docker Desktop to Linux containers (tray icon, then Switch to Linux containers)'
|
|
245
|
+
} else {
|
|
246
|
+
& docker compose version *> $null
|
|
247
|
+
if ($LASTEXITCODE -ne 0) {
|
|
248
|
+
$lines += "[$Tick] Docker daemon running"
|
|
249
|
+
$lines += '[!] Docker Compose not found'
|
|
250
|
+
$action = 'Update Docker Desktop (it includes Compose v2)'
|
|
251
|
+
} elseif ($needsUp -and ((& docker compose up --help 2>$null | Out-String) -notmatch '--wait-timeout')) {
|
|
252
|
+
# Start-GapStack needs `up --wait --wait-timeout`; an older Compose would fail there with
|
|
253
|
+
# "unknown flag" and the user would be told that a service is unhealthy.
|
|
254
|
+
$lines += "[$Tick] Docker daemon running"
|
|
255
|
+
$lines += '[!] Docker Compose is too old'
|
|
256
|
+
$action = 'Update Docker Desktop (it includes Compose v2)'
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (-not $action) { return $true }
|
|
261
|
+
$lines | ForEach-Object { Write-Host $_ }
|
|
262
|
+
Write-Host "Action: $action"
|
|
263
|
+
Write-Host "Then run: $rerun"
|
|
264
|
+
return $false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Test-PortInUse P: something accepts connections on 127.0.0.1:P, or Windows will not let us bind
|
|
268
|
+
# it (Hyper-V and WSL reserve some ranges; Docker then fails with "forbidden by its access
|
|
269
|
+
# permissions" even though nothing listens).
|
|
270
|
+
function Test-PortInUse($port) {
|
|
271
|
+
$client = New-Object Net.Sockets.TcpClient
|
|
272
|
+
try {
|
|
273
|
+
$pending = $client.BeginConnect('127.0.0.1', $port, $null, $null)
|
|
274
|
+
if ($pending.AsyncWaitHandle.WaitOne(500) -and $client.Connected) { return $true }
|
|
275
|
+
} catch {
|
|
276
|
+
} finally {
|
|
277
|
+
$client.Close()
|
|
278
|
+
}
|
|
279
|
+
$listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, $port)
|
|
280
|
+
try {
|
|
281
|
+
$listener.Start()
|
|
282
|
+
return $false
|
|
283
|
+
} catch {
|
|
284
|
+
return $true
|
|
285
|
+
} finally {
|
|
286
|
+
try { $listener.Stop() } catch { }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# Test-PortOwnedByGap P: a container of this project publishes P.
|
|
291
|
+
function Test-PortOwnedByGap($port) {
|
|
292
|
+
$id = & docker ps -q --filter "label=com.docker.compose.project=$GapProject" --filter "publish=$port" 2>$null
|
|
293
|
+
return [bool]$id
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Select-Port [STORED]: keep STORED when it is free or already ours, else the first free port of the
|
|
297
|
+
# range (D9). Returns $null only when the whole range is taken.
|
|
298
|
+
function Select-Port($stored) {
|
|
299
|
+
if ($stored -and ((-not (Test-PortInUse $stored)) -or (Test-PortOwnedByGap $stored))) { return [int]$stored }
|
|
300
|
+
foreach ($port in $GapPortFirst..$GapPortLast) {
|
|
301
|
+
if (-not (Test-PortInUse $port)) { return $port }
|
|
302
|
+
}
|
|
303
|
+
return $null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# Get-SvcState SERVICE: Healthy | Starting | Unhealthy | Restarting | Running | Stopped
|
|
307
|
+
function Get-SvcState($service) {
|
|
308
|
+
$id = Invoke-Dc ps -aq $service 2>$null | Select-Object -First 1
|
|
309
|
+
if (-not $id) { return 'Stopped' }
|
|
310
|
+
$state = & docker inspect --format '{{.State.Status}}/{{if .State.Health}}{{.State.Health.Status}}{{end}}' $id 2>$null
|
|
311
|
+
switch -Wildcard ("$state".Trim()) {
|
|
312
|
+
'running/healthy' { return 'Healthy' }
|
|
313
|
+
'running/starting' { return 'Starting' }
|
|
314
|
+
'running/unhealthy' { return 'Unhealthy' }
|
|
315
|
+
'running/' { return 'Running' }
|
|
316
|
+
'restarting/*' { return 'Restarting' }
|
|
317
|
+
default { return 'Stopped' }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Test-HttpOk PATH: HTTP 200 from the Admin UI (so through nginx, the path the user takes). No proxy:
|
|
322
|
+
# a corporate proxy setting must not intercept 127.0.0.1.
|
|
323
|
+
function Test-HttpOk($path) {
|
|
324
|
+
try {
|
|
325
|
+
$request = [Net.WebRequest]::Create("$(Get-UiUrl)$path")
|
|
326
|
+
$request.Proxy = $null
|
|
327
|
+
$request.Timeout = 5000
|
|
328
|
+
$response = $request.GetResponse()
|
|
329
|
+
try { return ([int]$response.StatusCode -eq 200) } finally { $response.Close() }
|
|
330
|
+
} catch {
|
|
331
|
+
return $false
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function Format-Arg($value) { '"' + $value + '"' }
|
|
336
|
+
|
|
337
|
+
# Get-DescendantIds ID: every process below ID, deepest first (pwsh on macOS or Linux only).
|
|
338
|
+
function Get-DescendantIds($id) {
|
|
339
|
+
foreach ($child in @(& pgrep -P $id 2>$null)) {
|
|
340
|
+
if ($child) {
|
|
341
|
+
Get-DescendantIds $child
|
|
342
|
+
$child
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Stop-ProcessTree PROC: the process and everything it started. docker.exe runs Compose as a separate
|
|
348
|
+
# docker-compose.exe (a grandchild of this script), which Kill() alone would leave waiting on its own.
|
|
349
|
+
function Stop-ProcessTree($proc) {
|
|
350
|
+
if ($OnWindows) {
|
|
351
|
+
& taskkill /PID $proc.Id /T /F *> $null
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
foreach ($id in @(Get-DescendantIds $proc.Id)) { & kill -TERM $id *> $null }
|
|
355
|
+
try { $proc.Kill() } catch { }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# The user PATH is read and written in the registry directly, not with
|
|
359
|
+
# [Environment]::SetEnvironmentVariable: that would store it as REG_SZ with every %VAR% already
|
|
360
|
+
# expanded, freezing entries such as %USERPROFILE%\AppData\Local\Microsoft\WindowsApps.
|
|
361
|
+
function Get-UserPathRaw {
|
|
362
|
+
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
|
|
363
|
+
if (-not $key) { return '' }
|
|
364
|
+
try {
|
|
365
|
+
return [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
366
|
+
} finally {
|
|
367
|
+
$key.Close()
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function Set-UserPathRaw($value) {
|
|
372
|
+
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
373
|
+
try {
|
|
374
|
+
if ($value) { $key.SetValue('Path', $value, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
|
375
|
+
else { $key.DeleteValue('Path', $false) }
|
|
376
|
+
} finally {
|
|
377
|
+
$key.Close()
|
|
378
|
+
}
|
|
379
|
+
# Removing a user variable that does not exist changes nothing, but .NET then broadcasts
|
|
380
|
+
# WM_SETTINGCHANGE, so Explorer and the terminals it starts from now on see the new PATH.
|
|
381
|
+
[Environment]::SetEnvironmentVariable('GAP_INSTALLER_PATH_REFRESH', $null, 'User')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Test-LauncherEntry ENTRY: a PATH entry that is the launcher folder (also when written with %VAR%).
|
|
385
|
+
function Test-LauncherEntry($entry) {
|
|
386
|
+
[Environment]::ExpandEnvironmentVariables($entry).TrimEnd('\') -ieq $LauncherDir
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# --- Does `gap` mean GAP in the user's own PowerShell? --------------------------------------------
|
|
390
|
+
# install.cmd runs install.ps1 with -NoProfile, so it cannot see an alias or function called `gap`
|
|
391
|
+
# that the user's profile defines, and PowerShell prefers those to gap.cmd on the PATH. So each
|
|
392
|
+
# PowerShell the user may open (Windows PowerShell, PowerShell 7) is started the way a terminal
|
|
393
|
+
# window starts it, with its profile, and asked how it resolves `gap`. Like opening a terminal,
|
|
394
|
+
# that runs the profile, under the machine's own execution policy (see Get-GapResolution).
|
|
395
|
+
$GapProbeTimeoutMs = 10000
|
|
396
|
+
|
|
397
|
+
# Get-GapResolution EXE: how EXE resolves `gap`, as @{ Kind = CommandType or 'None'; Source = path }.
|
|
398
|
+
# $null when it cannot say: a profile that exits, or hangs (killed after $GapProbeTimeoutMs).
|
|
399
|
+
# - The question goes in -EncodedCommand: Windows PowerShell 5.1 mangles inner double quotes in arguments.
|
|
400
|
+
# - The answer goes to a UTF-8 file, not to stdout: 5.1 writes redirected output in the console's
|
|
401
|
+
# code page, which turns a user folder such as C:\Users\Nguyen-with-accents into question marks.
|
|
402
|
+
# - `-ExecutionPolicy Bypass` (install.cmd) is kept in $env:PSExecutionPolicyPreference and would be
|
|
403
|
+
# inherited, loading a profile that the user's own windows refuse to run; it is left out here.
|
|
404
|
+
function Get-GapResolution($exe) {
|
|
405
|
+
$answerFile = [IO.Path]::GetTempFileName()
|
|
406
|
+
$out = [IO.Path]::GetTempFileName()
|
|
407
|
+
$err = [IO.Path]::GetTempFileName()
|
|
408
|
+
$target = "'" + $answerFile.Replace("'", "''") + "'"
|
|
409
|
+
$question = '$c = Get-Command gap -ErrorAction SilentlyContinue | Select-Object -First 1; ' +
|
|
410
|
+
'$a = if ($c) { "$($c.CommandType)|$($c.Source)" } else { ''None|'' }; ' +
|
|
411
|
+
"[IO.File]::WriteAllText($target, `$a, (New-Object Text.UTF8Encoding(`$false)))"
|
|
412
|
+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($question))
|
|
413
|
+
$policy = $env:PSExecutionPolicyPreference
|
|
414
|
+
try {
|
|
415
|
+
Remove-Item Env:PSExecutionPolicyPreference -ErrorAction SilentlyContinue
|
|
416
|
+
try {
|
|
417
|
+
$proc = Start-Process -FilePath $exe -ArgumentList @('-NoLogo', '-NonInteractive', '-EncodedCommand', $encoded) `
|
|
418
|
+
-NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
|
|
419
|
+
} finally {
|
|
420
|
+
if ($policy) { $env:PSExecutionPolicyPreference = $policy }
|
|
421
|
+
}
|
|
422
|
+
$null = $proc.Handle # keeps the handle so the process can be waited on and killed
|
|
423
|
+
if (-not $proc.WaitForExit($GapProbeTimeoutMs)) { Stop-ProcessTree $proc; return $null }
|
|
424
|
+
$answer = [IO.File]::ReadAllText($answerFile, [Text.Encoding]::UTF8)
|
|
425
|
+
$bar = $answer.IndexOf('|')
|
|
426
|
+
if ($bar -lt 1) { return $null }
|
|
427
|
+
return @{ Kind = $answer.Substring(0, $bar); Source = $answer.Substring($bar + 1) }
|
|
428
|
+
} catch {
|
|
429
|
+
return $null
|
|
430
|
+
} finally {
|
|
431
|
+
Remove-Item -LiteralPath $answerFile, $out, $err -Force -ErrorAction SilentlyContinue
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
# Get-ProbeShells: the PowerShells found on this machine, as @{ Name; Path }.
|
|
436
|
+
function Get-ProbeShells {
|
|
437
|
+
foreach ($candidate in @(@{ Exe = 'powershell'; Name = 'Windows PowerShell' }, @{ Exe = 'pwsh'; Name = 'PowerShell 7' })) {
|
|
438
|
+
$found = Get-Command $candidate.Exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
439
|
+
if ($found) { @{ Name = $candidate.Name; Path = $found.Source } }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# Test-NpmShim SOURCE: SOURCE is the command `npm install -g` made for this package. On Windows npm puts a shim (gap.cmd,
|
|
444
|
+
# gap.ps1) in its global folder, next to node_modules\@atomiton-dev\gap-cli, and this script runs from the installer
|
|
445
|
+
# folder inside that package. Compared by place, not by what the shim says. Not checked on Windows: the layout is
|
|
446
|
+
# npm's documented one, it has not been looked at there.
|
|
447
|
+
function Test-NpmShim($source) {
|
|
448
|
+
if ((Get-Channel) -ne 'npm') { return $false }
|
|
449
|
+
$package = [IO.Path]::GetFullPath((Split-Path -Parent $GapSource)).TrimEnd('\')
|
|
450
|
+
$shimDir = [IO.Path]::GetDirectoryName($source)
|
|
451
|
+
$expected = [IO.Path]::GetFullPath((Join-Path $shimDir 'node_modules\@atomiton-dev\gap-cli')).TrimEnd('\')
|
|
452
|
+
return ($expected -ieq $package)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# Get-GapResolutionReport: asks each PowerShell (Get-ProbeShells) and returns @{ Asked; Shadowed; NotFound; Lines }:
|
|
456
|
+
# Lines say what to do where `gap` is not gap.cmd, and are empty where it is; Shadowed says there was
|
|
457
|
+
# such a place. Asked is $false when no PowerShell could be asked, so that the caller can fall back
|
|
458
|
+
# to what it knows without one.
|
|
459
|
+
# Nothing is printed here: a profile may clear the console, so the caller asks before its summary.
|
|
460
|
+
function Get-GapResolutionReport {
|
|
461
|
+
$asked = $false
|
|
462
|
+
$shadowed = $false
|
|
463
|
+
$notFound = $false
|
|
464
|
+
$lines = @()
|
|
465
|
+
$launcher = Join-Path $LauncherDir 'gap.cmd'
|
|
466
|
+
# What works when `gap` does not: the ZIP's launcher, or (npm) the script the package provides, which needs the
|
|
467
|
+
# flags that gap.cmd carries itself.
|
|
468
|
+
$fallback = if ((Get-Channel) -eq 'npm') { "powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`"" } else { $launcher }
|
|
469
|
+
$fallbackHint = if ((Get-Channel) -eq 'npm') { "powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status" } else { '& "$HOME\.gap\bin\gap.cmd" status' }
|
|
470
|
+
foreach ($shell in @(Get-ProbeShells)) {
|
|
471
|
+
$found = Get-GapResolution $shell.Path
|
|
472
|
+
if (-not $found) { continue }
|
|
473
|
+
$asked = $true
|
|
474
|
+
$kind = $found.Kind
|
|
475
|
+
# The install does not mind: this folder is not on the PATH of a window opened before, and the PATH note covers it.
|
|
476
|
+
# doctor does (NotFound): it says `gap` is not found.
|
|
477
|
+
if ($kind -eq 'None') { $notFound = $true; continue }
|
|
478
|
+
if ($kind -eq 'Application' -or $kind -eq 'ExternalScript') {
|
|
479
|
+
if ([IO.Path]::GetDirectoryName($found.Source) -ieq $LauncherDir) { continue }
|
|
480
|
+
if (Test-NpmShim $found.Source) { continue }
|
|
481
|
+
$shadowed = $true
|
|
482
|
+
$lines += " Warning: in $($shell.Name), `"gap`" runs $($found.Source) (another program), not GAP."
|
|
483
|
+
if ((Get-Channel) -eq 'npm') { $lines += " Run GAP with $fallback status, or put npm's global folder first on your PATH." }
|
|
484
|
+
else { $lines += " Run GAP with $launcher status, or put $LauncherDir first on your PATH." }
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
$shadowed = $true
|
|
488
|
+
$what = if ($kind -eq 'Alias') { 'an alias' } elseif ($kind -eq 'Function') { 'a function' } else { "a PowerShell command ($kind)" }
|
|
489
|
+
$lines += " Warning: in $($shell.Name), `"gap`" is $what that runs something else, so `"gap status`" does not run GAP there."
|
|
490
|
+
if ($kind -eq 'Alias' -or $kind -eq 'Function') {
|
|
491
|
+
# The profile folder may not exist yet (the alias can come from another profile), and an alias
|
|
492
|
+
# made with -Option ReadOnly only goes with -Force.
|
|
493
|
+
$lines += ' To fix that for good, run this once in that window, then open a new one:'
|
|
494
|
+
$lines += " New-Item -Force -ItemType Directory (Split-Path `$PROFILE) | Out-Null; Add-Content `$PROFILE 'Remove-Item -Force Alias:gap, Function:gap -ErrorAction SilentlyContinue'"
|
|
495
|
+
}
|
|
496
|
+
$lines += " Until then, run GAP with: $fallbackHint"
|
|
497
|
+
}
|
|
498
|
+
return @{ Asked = $asked; Shadowed = $shadowed; NotFound = $notFound; Lines = $lines }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
# Start-GapStack: docker compose up -d --wait, bounded (D26). A bare `up -d` hangs forever when the
|
|
502
|
+
# Kernel crash-loops (A4.11), and --wait alone would sit out the whole timeout, so a Restarting
|
|
503
|
+
# service stops the wait early. Returns $true when all three services are healthy.
|
|
504
|
+
function Start-GapStack {
|
|
505
|
+
$out = [IO.Path]::GetTempFileName()
|
|
506
|
+
$err = [IO.Path]::GetTempFileName()
|
|
507
|
+
try {
|
|
508
|
+
$composeArgs = @('compose', '-p', $GapProject,
|
|
509
|
+
'--project-directory', (Format-Arg $GapHome),
|
|
510
|
+
'-f', (Format-Arg (Join-Path $GapHome 'docker-compose.yml')),
|
|
511
|
+
'--env-file', (Format-Arg (Get-EnvPath)),
|
|
512
|
+
'up', '-d', '--wait', '--wait-timeout', "$GapUpTimeout")
|
|
513
|
+
$proc = Start-Process -FilePath 'docker' -ArgumentList $composeArgs -NoNewWindow -PassThru `
|
|
514
|
+
-RedirectStandardOutput $out -RedirectStandardError $err
|
|
515
|
+
$null = $proc.Handle # keeps the handle so ExitCode is readable after the process ends
|
|
516
|
+
while (-not $proc.HasExited) {
|
|
517
|
+
foreach ($service in 'postgres', 'kernel', 'admin-ui') {
|
|
518
|
+
if ((Get-SvcState $service) -eq 'Restarting') {
|
|
519
|
+
Stop-ProcessTree $proc
|
|
520
|
+
Write-Warn "$service keeps restarting"
|
|
521
|
+
Write-Host "Run: gap logs $service"
|
|
522
|
+
if ($service -eq 'kernel') {
|
|
523
|
+
Write-Host 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
|
|
524
|
+
}
|
|
525
|
+
return $false
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
Start-Sleep -Seconds 3
|
|
529
|
+
}
|
|
530
|
+
$proc.WaitForExit()
|
|
531
|
+
if ($proc.ExitCode -ne 0) {
|
|
532
|
+
Write-Warn 'GAP did not start: a service is not healthy'
|
|
533
|
+
(@(Get-Content -LiteralPath $out) + @(Get-Content -LiteralPath $err)) | Select-Object -Last 5 | ForEach-Object { Write-Host $_ }
|
|
534
|
+
Write-Host 'Run: gap logs kernel'
|
|
535
|
+
if ((Get-SvcState 'kernel') -ne 'Healthy' -and (Get-SvcState 'postgres') -eq 'Healthy') {
|
|
536
|
+
Write-Host 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
|
|
537
|
+
}
|
|
538
|
+
return $false
|
|
539
|
+
}
|
|
540
|
+
return $true
|
|
541
|
+
} finally {
|
|
542
|
+
Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function Open-Url($url) {
|
|
547
|
+
try { Start-Process $url } catch { }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
# --- gap version and gap doctor (#1986, #2012) -------------------------------------------------------------
|
|
551
|
+
# Both only read. doctor never starts, stops or pulls anything, never writes .env, a launcher or the user PATH. It does
|
|
552
|
+
# run the user's own PowerShell the way a new window starts it (Get-GapResolutionReport), as install.ps1 does.
|
|
553
|
+
# NOT RUN: there is no Windows (and no pwsh) on the machine this was written on; see the notes at the places that rest on
|
|
554
|
+
# something that was not looked at.
|
|
555
|
+
$GapMinNodeMajor = 18 # the same number as MIN_NODE_MAJOR in npm\lib\launcher.js, which refuses to start below it
|
|
556
|
+
|
|
557
|
+
function Test-Installed { Test-Path -LiteralPath (Get-EnvPath) }
|
|
558
|
+
|
|
559
|
+
# Get-CliVersion: the version of the npm package that runs this script. Empty on the ZIP channel (the VERSION file of an
|
|
560
|
+
# unzipped folder is the ZIP's own, not a package).
|
|
561
|
+
function Get-CliVersion {
|
|
562
|
+
if ((Get-Channel) -ne 'npm') { return '' }
|
|
563
|
+
$file = Join-Path $GapSource 'VERSION'
|
|
564
|
+
if (-not (Test-Path -LiteralPath $file)) { return '' }
|
|
565
|
+
return ([IO.File]::ReadAllText($file)).Trim()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
# Get-InstalledVersion: GAP_VERSION of the install in $GapHome. Empty when GAP is not installed; 'unknown' when there is an
|
|
569
|
+
# .env that does not say (no number is guessed).
|
|
570
|
+
function Get-InstalledVersion {
|
|
571
|
+
if (-not (Test-Installed)) { return '' }
|
|
572
|
+
$v = Get-EnvValue 'GAP_VERSION'
|
|
573
|
+
if ($v) { return $v }
|
|
574
|
+
return 'unknown'
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
# Compare-GapVersion A B: -1, 0 or 1 (A older than, equal to, newer than B); $null when it cannot tell. The same reading as
|
|
578
|
+
# version_cmp in gap-lib.sh: major.minor.patch with an optional -suffix, the numbers compared as numbers, the suffix the same
|
|
579
|
+
# (compared exactly, case too: -ceq, not -eq), nine digits at most. Callers compare the result with -eq, never as a boolean.
|
|
580
|
+
$script:VersionPattern = '^([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})(-[A-Za-z0-9._-]+)?$'
|
|
581
|
+
function Compare-GapVersion($a, $b) {
|
|
582
|
+
$ma = [regex]::Match("$a", $script:VersionPattern)
|
|
583
|
+
$mb = [regex]::Match("$b", $script:VersionPattern)
|
|
584
|
+
if (-not $ma.Success -or -not $mb.Success) { return $null }
|
|
585
|
+
if ($ma.Groups[4].Value -cne $mb.Groups[4].Value) { return $null }
|
|
586
|
+
foreach ($i in 1, 2, 3) {
|
|
587
|
+
$x = [int64]$ma.Groups[$i].Value
|
|
588
|
+
$y = [int64]$mb.Groups[$i].Value
|
|
589
|
+
if ($x -gt $y) { return 1 }
|
|
590
|
+
if ($x -lt $y) { return -1 }
|
|
591
|
+
}
|
|
592
|
+
return 0
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
# Test-PackageOlder: the npm package that runs this script is older than the installed GAP, by numbers (false when equal,
|
|
596
|
+
# newer, not comparable, or on the ZIP channel). `gap install` refuses to go back to it.
|
|
597
|
+
function Test-PackageOlder {
|
|
598
|
+
if ((Get-Channel) -ne 'npm') { return $false }
|
|
599
|
+
$pkg = Get-CliVersion
|
|
600
|
+
$installed = Get-InstalledVersion
|
|
601
|
+
if (-not $pkg -or -not $installed) { return $false }
|
|
602
|
+
return ((Compare-GapVersion $installed $pkg) -eq 1)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# Test-PackageNewer: the mirror of Test-PackageOlder: the npm package that runs this script is newer than the installed GAP, by
|
|
606
|
+
# numbers (`gap update` moves to it). False when equal, older, not comparable, or on the ZIP channel.
|
|
607
|
+
function Test-PackageNewer {
|
|
608
|
+
if ((Get-Channel) -ne 'npm') { return $false }
|
|
609
|
+
$pkg = Get-CliVersion
|
|
610
|
+
$installed = Get-InstalledVersion
|
|
611
|
+
if (-not $pkg -or -not $installed) { return $false }
|
|
612
|
+
return ((Compare-GapVersion $installed $pkg) -eq -1)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# Write-NewerPackageNote: one line for `gap status` and `gap start`.
|
|
616
|
+
function Write-NewerPackageNote {
|
|
617
|
+
if (Test-PackageNewer) { Write-Host "A newer version ($(Get-CliVersion)) is in the npm package. Run: gap update" }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function Write-OlderPackageHelp {
|
|
621
|
+
Write-Host ' gap does not go back: what a newer version did to the database cannot be undone.'
|
|
622
|
+
Write-Host ' Install the newer package first: npm install -g @atomiton-dev/gap-cli@latest'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
# Get-ManifestErrorKind TEXT: unauthorized | denied | notfound | network | other, for what `docker manifest inspect` printed.
|
|
626
|
+
# The same texts as classify_manifest_error in gap-lib.sh, measured on ghcr.io on macOS (04/11/2026); `docker manifest
|
|
627
|
+
# inspect` is part of the Docker CLI, which is the same code on Windows, but this has not been measured there. A valid token
|
|
628
|
+
# that may not read the package prints the single word `denied`; it counts only as the WHOLE text, because `permission denied`
|
|
629
|
+
# (config file, Docker socket) is not the registry.
|
|
630
|
+
function Get-ManifestErrorKind($text) {
|
|
631
|
+
$t = "$text".ToLower()
|
|
632
|
+
if ($t.Contains('failed to configure transport') -or $t.Contains('error pinging v2 registry') -or $t.Contains('failed to do request')) { return 'network' }
|
|
633
|
+
if ($t.Contains('": unauthorized')) { return 'unauthorized' }
|
|
634
|
+
if ($t.Contains('": denied')) { return 'denied' }
|
|
635
|
+
if ($t.Trim() -eq 'denied') { return 'denied' }
|
|
636
|
+
if ($t.Contains('manifest unknown')) { return 'notfound' }
|
|
637
|
+
return 'other'
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
$script:DoctorProblems = 0 # `[!]` lines of this run
|
|
641
|
+
$script:DoctorDocker = $false # $true when Docker is ready: what needs it is skipped otherwise
|
|
642
|
+
|
|
643
|
+
# Add-DoctorProblem MESSAGE: a `[!]` line, counted. Write-DoctorUnsure MESSAGE: something that could not be checked; it says
|
|
644
|
+
# nothing is wrong, so it is not counted. The lines that say what to do come after them, indented.
|
|
645
|
+
function Add-DoctorProblem($message) { Write-Warn $message; $script:DoctorProblems++ }
|
|
646
|
+
function Write-DoctorUnsure($message) { Write-Host "[?] $message" }
|
|
647
|
+
|
|
648
|
+
# Get-DoctorInstallHint: the command that installs, in the words of this channel.
|
|
649
|
+
function Get-DoctorInstallHint {
|
|
650
|
+
if ((Get-Channel) -eq 'npm') { return 'gap install' }
|
|
651
|
+
$ps1 = Join-Path $GapSource 'install.ps1'
|
|
652
|
+
if (Test-Path -LiteralPath $ps1) { return "powershell -ExecutionPolicy Bypass -File `"$ps1`"" }
|
|
653
|
+
return 'install.ps1 (in the GAP installer folder)'
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function Test-DoctorNode {
|
|
657
|
+
if ((Get-Channel) -ne 'npm') { Write-Ok 'Node.js is not needed for the ZIP installer'; return }
|
|
658
|
+
$v = ''
|
|
659
|
+
if (Get-Command node -ErrorAction SilentlyContinue) { $v = "$(& node -v 2>$null)".Trim() }
|
|
660
|
+
$major = 0
|
|
661
|
+
if ($v -match '^v(\d+)\.') { $major = [int]$Matches[1] }
|
|
662
|
+
if ($major -eq 0) {
|
|
663
|
+
Add-DoctorProblem 'Node.js not found'
|
|
664
|
+
Write-Host " Action: install Node.js $GapMinNodeMajor or newer from https://nodejs.org"
|
|
665
|
+
} elseif ($major -lt $GapMinNodeMajor) {
|
|
666
|
+
Add-DoctorProblem "Node.js $v is too old: GAP's npm package needs Node $GapMinNodeMajor or newer"
|
|
667
|
+
Write-Host ' Action: install a current Node.js from https://nodejs.org'
|
|
668
|
+
} else {
|
|
669
|
+
Write-Ok "Node.js $v"
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
# Test-DoctorDocker: Test-Prereqs as every command runs it (it prints the failing step and what to do), but a failure does not
|
|
674
|
+
# end doctor: what does not need Docker is still looked at. (Test-Prereqs does not look for curl on Windows.)
|
|
675
|
+
function Test-DoctorDocker {
|
|
676
|
+
if (Test-Prereqs 'gap doctor' $true) {
|
|
677
|
+
$script:DoctorDocker = $true
|
|
678
|
+
Write-Ok "OS: $(Get-OsName)"
|
|
679
|
+
Write-Ok 'Docker is ready (daemon running, Linux containers, Compose v2)'
|
|
680
|
+
} else {
|
|
681
|
+
$script:DoctorDocker = $false
|
|
682
|
+
$script:DoctorProblems++
|
|
683
|
+
if ((Get-Channel) -eq 'npm') {
|
|
684
|
+
Write-DoctorUnsure "Access to GAP's images and the state of the GAP services were not checked: Docker is not ready"
|
|
685
|
+
} else {
|
|
686
|
+
Write-DoctorUnsure 'The state of the GAP services was not checked: Docker is not ready'
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
# Test-DoctorPorts: what `gap install` and `gap start` would find: Select-Port itself decides, so the two cannot disagree.
|
|
692
|
+
function Test-DoctorPorts {
|
|
693
|
+
$stored = ''
|
|
694
|
+
if (Test-Installed) { $stored = Get-EnvValue 'GAP_UI_PORT' }
|
|
695
|
+
$port = Select-Port $stored
|
|
696
|
+
if (-not $port) {
|
|
697
|
+
Add-DoctorProblem 'No free port for the Admin UI'
|
|
698
|
+
Write-Host " Action: free a port between $GapPortFirst and $GapPortLast"
|
|
699
|
+
} elseif (-not $stored) {
|
|
700
|
+
Write-Ok "Port $port is free for the Admin UI"
|
|
701
|
+
} elseif ("$port" -ne "$stored") {
|
|
702
|
+
Write-Ok "The Admin UI port $stored is used by another program; the next gap start will use $port"
|
|
703
|
+
} elseif (Test-PortInUse $port) {
|
|
704
|
+
Write-Ok "The Admin UI port $stored is in use by GAP"
|
|
705
|
+
} else {
|
|
706
|
+
Write-Ok "The Admin UI port $stored is free"
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
# Test-DoctorGapCommand: does `gap` mean GAP in the user's own PowerShell? Each PowerShell is asked as install.ps1 asks it (a
|
|
711
|
+
# new window: its profile runs); only when none can be asked is this process's own view used. The npm channel's global
|
|
712
|
+
# folder is `npm prefix -g` itself on Windows (npm puts gap.cmd there), not a bin subfolder: NOT looked at on Windows.
|
|
713
|
+
function Test-DoctorGapCommand {
|
|
714
|
+
$npm = ((Get-Channel) -eq 'npm')
|
|
715
|
+
# A ZIP that is not installed yet: install.ps1 puts `gap` on the PATH, and "GAP is not installed" already says to run it.
|
|
716
|
+
if (-not $npm -and -not (Test-Installed)) { return }
|
|
717
|
+
$zipLauncher = Join-Path $LauncherDir 'gap.cmd'
|
|
718
|
+
if ($npm -and (Test-Path -LiteralPath $zipLauncher)) {
|
|
719
|
+
Add-DoctorProblem "$zipLauncher is a launcher from a ZIP install of GAP and may run instead of the npm command"
|
|
720
|
+
Write-Host ' Use one channel per machine; the data is shared. To keep only the npm command, delete that file.'
|
|
721
|
+
}
|
|
722
|
+
$report = Get-GapResolutionReport
|
|
723
|
+
$found = $null
|
|
724
|
+
if (-not $report.Asked) {
|
|
725
|
+
$c = Get-Command gap -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
726
|
+
if (-not $c) {
|
|
727
|
+
$report.NotFound = $true
|
|
728
|
+
} else {
|
|
729
|
+
$found = $c
|
|
730
|
+
if ($c.CommandType -eq 'Application' -or $c.CommandType -eq 'ExternalScript') {
|
|
731
|
+
$isLauncher = ([IO.Path]::GetDirectoryName($c.Source) -ieq $LauncherDir)
|
|
732
|
+
$ours = if ($npm) { Test-NpmShim $c.Source } else { $isLauncher }
|
|
733
|
+
# The ZIP launcher in front of the npm command is the one reported above, not a second problem.
|
|
734
|
+
if (-not $ours -and -not ($npm -and $isLauncher)) { $report.Shadowed = $true }
|
|
735
|
+
} else {
|
|
736
|
+
$report.Shadowed = $true # an alias or a function
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if ($report.Shadowed) {
|
|
741
|
+
Add-DoctorProblem 'In your PowerShell, "gap" does not run GAP'
|
|
742
|
+
if ($report.Asked) { $report.Lines | ForEach-Object { Write-Host $_ } }
|
|
743
|
+
else { Write-Host " It runs $($found.Source). Run GAP with: powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status" }
|
|
744
|
+
}
|
|
745
|
+
# Both can be true: one PowerShell has its own `gap`, another has none (the bash side reports each shell as well).
|
|
746
|
+
if ($report.NotFound) {
|
|
747
|
+
Add-DoctorProblem '"gap" is not found: it is not on the PATH'
|
|
748
|
+
if ($npm) {
|
|
749
|
+
$prefix = ''
|
|
750
|
+
if (Get-Command npm -ErrorAction SilentlyContinue) { $prefix = "$(& npm prefix -g 2>$null)".Trim() }
|
|
751
|
+
if ($prefix) { Write-Host " Action: add this folder to your PATH: $prefix" }
|
|
752
|
+
else { Write-Host " Action: add npm's global folder to your PATH (find it with: npm prefix -g)" }
|
|
753
|
+
} else {
|
|
754
|
+
Write-Host " Action: add this folder to your user PATH: $LauncherDir"
|
|
755
|
+
}
|
|
756
|
+
Write-Host " Until then, run GAP with: powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status"
|
|
757
|
+
}
|
|
758
|
+
if (-not $report.Shadowed -and -not $report.NotFound) {
|
|
759
|
+
Write-Ok '"gap" runs GAP in your PowerShell'
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
# Join-DoctorRefs REFS: the references, comma-separated.
|
|
764
|
+
function Join-DoctorRefs($refs) { (@($refs) -join ', ') }
|
|
765
|
+
|
|
766
|
+
# Test-DoctorRegistry: the npm channel pulls from a private registry, so say whether this machine may read the three images.
|
|
767
|
+
# `docker manifest inspect` reads a manifest and downloads no layer (it is not `docker pull`). The three run side by side, as
|
|
768
|
+
# in gap-lib.sh: Docker stops waiting for an unreachable registry after 30 seconds by itself (measured on macOS), once, not
|
|
769
|
+
# three times over. The prefix is the channel's, not .env's.
|
|
770
|
+
$script:RegistryReadable = $false # $true when Test-DoctorRegistry could read all three images (`gap login` exits 0 only then)
|
|
771
|
+
|
|
772
|
+
function Test-DoctorRegistry {
|
|
773
|
+
$script:RegistryReadable = $false
|
|
774
|
+
if ((Get-Channel) -ne 'npm' -or -not $script:DoctorDocker) { return }
|
|
775
|
+
$version = Get-CliVersion
|
|
776
|
+
if (-not $version) { Write-DoctorUnsure "GAP's images were not checked: this package has no VERSION file"; return }
|
|
777
|
+
$prefix = Get-ChannelPrefix
|
|
778
|
+
$registryHost = $prefix.Split('/')[0]
|
|
779
|
+
$refs = @((Get-ImageRef $prefix 'gap-kernel' $version), (Get-ImageRef $prefix 'gap-admin-ui' $version), (Get-ImageRef $prefix 'gap-postgres' '16'))
|
|
780
|
+
Write-Host 'Checking access to the image registry...'
|
|
781
|
+
$jobs = @()
|
|
782
|
+
try {
|
|
783
|
+
foreach ($ref in $refs) {
|
|
784
|
+
$out = [IO.Path]::GetTempFileName()
|
|
785
|
+
$err = [IO.Path]::GetTempFileName()
|
|
786
|
+
$proc = Start-Process -FilePath 'docker' -ArgumentList @('manifest', 'inspect', $ref) -NoNewWindow -PassThru `
|
|
787
|
+
-RedirectStandardOutput $out -RedirectStandardError $err
|
|
788
|
+
$null = $proc.Handle # keeps the handle so ExitCode is readable after the process ends
|
|
789
|
+
$jobs += @{ Ref = $ref; Proc = $proc; Out = $out; Err = $err }
|
|
790
|
+
}
|
|
791
|
+
$unauthorized = @(); $denied = @(); $notFound = @(); $network = @(); $other = @(); $otherText = ''
|
|
792
|
+
foreach ($job in $jobs) {
|
|
793
|
+
$job.Proc.WaitForExit()
|
|
794
|
+
if ($job.Proc.ExitCode -eq 0) { continue }
|
|
795
|
+
$text = (@(Get-Content -LiteralPath $job.Err) -join "`n")
|
|
796
|
+
switch (Get-ManifestErrorKind $text) {
|
|
797
|
+
'unauthorized' { $unauthorized += $job.Ref }
|
|
798
|
+
'denied' { $denied += $job.Ref }
|
|
799
|
+
'notfound' { $notFound += $job.Ref }
|
|
800
|
+
'network' { $network += $job.Ref }
|
|
801
|
+
default { $other += $job.Ref; $otherText = $text }
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
} finally {
|
|
805
|
+
foreach ($job in $jobs) { Remove-Item -LiteralPath $job.Out, $job.Err -Force -ErrorAction SilentlyContinue }
|
|
806
|
+
}
|
|
807
|
+
if (($unauthorized.Count + $denied.Count + $notFound.Count + $network.Count + $other.Count) -eq 0) {
|
|
808
|
+
Write-Ok "You can read GAP's images in $registryHost"
|
|
809
|
+
$script:RegistryReadable = $true
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
if ($unauthorized.Count -gt 0) {
|
|
813
|
+
Add-DoctorProblem "GAP's images are private and this machine is not logged in to $registryHost"
|
|
814
|
+
Write-LoginSteps
|
|
815
|
+
}
|
|
816
|
+
if ($denied.Count -gt 0) {
|
|
817
|
+
Add-DoctorProblem "$registryHost refused access to $(Join-DoctorRefs $denied)"
|
|
818
|
+
Write-DeniedHelp
|
|
819
|
+
}
|
|
820
|
+
if ($notFound.Count -gt 0) {
|
|
821
|
+
Add-DoctorProblem "$(Join-DoctorRefs $notFound) was not found in the registry"
|
|
822
|
+
Write-Host "This GAP package is version $version. That version may not be published yet; check the version you were given."
|
|
823
|
+
}
|
|
824
|
+
if ($network.Count -gt 0) {
|
|
825
|
+
Write-DoctorUnsure "Could not reach ${registryHost}: access to $(Join-DoctorRefs $network) was not checked"
|
|
826
|
+
Write-Host ' Check your internet connection (and proxy settings). Nothing is wrong with GAP itself.'
|
|
827
|
+
}
|
|
828
|
+
if ($other.Count -gt 0) {
|
|
829
|
+
Write-DoctorUnsure "Could not check access to $(Join-DoctorRefs $other)"
|
|
830
|
+
@("$otherText" -split "`r?`n" | Where-Object { $_ }) | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" }
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Test-DoctorServices: the three services, as `gap status` reads them (Get-SvcState), and the Admin UI through its own address.
|
|
835
|
+
function Test-DoctorServices {
|
|
836
|
+
$pg = Get-SvcState 'postgres'
|
|
837
|
+
$kernel = Get-SvcState 'kernel'
|
|
838
|
+
$ui = Get-SvcState 'admin-ui'
|
|
839
|
+
if ($pg -eq 'Stopped' -and $kernel -eq 'Stopped' -and $ui -eq 'Stopped') {
|
|
840
|
+
Add-DoctorProblem 'GAP is not running'
|
|
841
|
+
Write-Host ' Run: gap start'
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
foreach ($item in @(@('postgres', 'PostgreSQL', $pg), @('kernel', 'GAP Kernel', $kernel), @('admin-ui', 'GAP Admin UI', $ui))) {
|
|
845
|
+
$service = $item[0]; $name = $item[1]; $state = $item[2]
|
|
846
|
+
switch ($state) {
|
|
847
|
+
'Healthy' { }
|
|
848
|
+
'Restarting' {
|
|
849
|
+
Add-DoctorProblem "$service keeps restarting"
|
|
850
|
+
Write-Host " Run: gap logs $service"
|
|
851
|
+
if ($service -eq 'kernel') {
|
|
852
|
+
Write-Host ' If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
'Stopped' { Add-DoctorProblem "$name is stopped"; Write-Host ' Run: gap start' }
|
|
856
|
+
'Starting' { Add-DoctorProblem "$name is still starting"; Write-Host ' Wait a minute, then run: gap doctor' }
|
|
857
|
+
default { Add-DoctorProblem "$name is $($state.ToLower())"; Write-Host " Run: gap logs $service" }
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (-not ($pg -eq 'Healthy' -and $kernel -eq 'Healthy' -and $ui -eq 'Healthy')) { return }
|
|
861
|
+
Write-Ok 'PostgreSQL, GAP Kernel and GAP Admin UI are healthy'
|
|
862
|
+
if (Test-HttpOk '/system/health') {
|
|
863
|
+
Write-Ok "The Admin UI answers at $(Get-UiUrl)"
|
|
864
|
+
} else {
|
|
865
|
+
Add-DoctorProblem "The Admin UI does not answer at $(Get-UiUrl)"
|
|
866
|
+
Write-Host ' Run: gap logs kernel'
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
# Test-DoctorInstall: is GAP installed here, in the version this package expects, and running.
|
|
871
|
+
function Test-DoctorInstall {
|
|
872
|
+
if (-not (Test-Installed)) {
|
|
873
|
+
$hasData = $false
|
|
874
|
+
if ($script:DoctorDocker) {
|
|
875
|
+
& docker volume inspect "${GapProject}_pgdata" *> $null
|
|
876
|
+
$hasData = ($LASTEXITCODE -eq 0)
|
|
877
|
+
}
|
|
878
|
+
if ($hasData) {
|
|
879
|
+
Add-DoctorProblem "GAP is not installed here, but data from an earlier install is in the Docker volume ${GapProject}_pgdata"
|
|
880
|
+
Write-Host " Run: $(Get-DoctorInstallHint) (it keeps that data)"
|
|
881
|
+
} else {
|
|
882
|
+
Add-DoctorProblem "GAP is not installed in $GapHome"
|
|
883
|
+
Write-Host " Run: $(Get-DoctorInstallHint)"
|
|
884
|
+
}
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
$installed = Get-InstalledVersion
|
|
888
|
+
$pkg = Get-CliVersion
|
|
889
|
+
if ($installed -eq 'unknown') {
|
|
890
|
+
Add-DoctorProblem "GAP_VERSION is missing from $(Get-EnvPath)"
|
|
891
|
+
Write-Host " Run: $(Get-DoctorInstallHint)"
|
|
892
|
+
} else {
|
|
893
|
+
Write-Ok "GAP $installed is installed in $GapHome"
|
|
894
|
+
if ($pkg -and $pkg -ne $installed) {
|
|
895
|
+
Add-DoctorProblem "The installed version ($installed) is not the version of this package ($pkg)"
|
|
896
|
+
if (Test-PackageNewer) { Write-Host ' Run: gap update (it backs up first)' }
|
|
897
|
+
elseif (Test-PackageOlder) { Write-Host ' Install the newer package: npm install -g @atomiton-dev/gap-cli@latest' }
|
|
898
|
+
else { Write-Host " Run: $(Get-DoctorInstallHint)" }
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (-not (Test-Path -LiteralPath (Join-Path $GapHome 'docker-compose.yml'))) {
|
|
902
|
+
Add-DoctorProblem "docker-compose.yml is missing in $GapHome"
|
|
903
|
+
Write-Host " Run: $(Get-DoctorInstallHint) again to put it back"
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
if ($script:DoctorDocker) { Test-DoctorServices }
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
# --- gap update (#1986, #2014) -------------------------------------------------------------------------------------
|
|
910
|
+
# The same command as `gap update` of the bash gap (gap-lib.sh, gap), in the same order: npm channel only, the versions compared
|
|
911
|
+
# as numbers, no step back, the new images pulled first, then a checked backup, then the new compose file and version, then the
|
|
912
|
+
# stack. Nothing is rolled back and nothing is deleted.
|
|
913
|
+
# What is not the same, on purpose:
|
|
914
|
+
# - Windows PowerShell 5.1 turns what a native command writes into text, which would corrupt a dump or a tar archive. So the
|
|
915
|
+
# backup never uses a PowerShell pipeline, `>` or Out-File for docker's output: .NET starts docker, and copies its output
|
|
916
|
+
# stream byte for byte into the file (gzip by .NET for the dump; tar gzips inside the container). The checks of the backup
|
|
917
|
+
# are .NET too.
|
|
918
|
+
# - the commands that go back to the old version are written for PowerShell (Copy-Item, docker cp, a bind mount), because
|
|
919
|
+
# `gunzip -c file | docker ...` and `cat file | docker ...` of the bash text are pipelines of a binary file.
|
|
920
|
+
# - the backup folder is not chmod-ed: on Windows it inherits the permissions of the user's folder (~\.gap is under it).
|
|
921
|
+
# NOT RUN on Windows or on Windows PowerShell 5.1: what rests on them is the lookup of docker.exe by Process.Start, the free
|
|
922
|
+
# space of a drive (DriveInfo), and the way `C:\...` paths are given to `docker cp` and `-v`. The rest was run with pwsh 7 on
|
|
923
|
+
# Linux against a fake docker (test_installer_update_powershell.py) and against a real Docker.
|
|
924
|
+
|
|
925
|
+
$script:BackupDir = ''
|
|
926
|
+
$script:PgDumpEnd = '-- PostgreSQL database cluster dump complete'
|
|
927
|
+
$script:BackupSpaceMarginPercent = 5
|
|
928
|
+
|
|
929
|
+
# Get-DcArgs: the start of every `docker compose` call of the backup (the same project, folder, files as Invoke-Dc), as an
|
|
930
|
+
# array of arguments for Invoke-DockerStream; a path with a space is quoted.
|
|
931
|
+
function Get-DcArgs {
|
|
932
|
+
@('compose', '-p', $GapProject,
|
|
933
|
+
'--project-directory', (Format-Arg $GapHome),
|
|
934
|
+
'-f', (Format-Arg (Join-Path $GapHome 'docker-compose.yml')),
|
|
935
|
+
'--env-file', (Format-Arg (Get-EnvPath)))
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
# Invoke-DockerStream ARGS STREAM: run docker with the arguments, standard input closed (what `-T </dev/null` does in bash),
|
|
939
|
+
# and copy its standard output byte for byte into STREAM. Returns an object with ExitCode and Error (what it wrote to standard
|
|
940
|
+
# error, as text). Never throws: a docker that cannot be started is ExitCode 127 and says why.
|
|
941
|
+
function Invoke-DockerStream($dockerArgs, $outStream) {
|
|
942
|
+
$proc = $null
|
|
943
|
+
try {
|
|
944
|
+
$psi = New-Object Diagnostics.ProcessStartInfo
|
|
945
|
+
$psi.FileName = 'docker'
|
|
946
|
+
$psi.Arguments = (@($dockerArgs) -join ' ')
|
|
947
|
+
$psi.UseShellExecute = $false
|
|
948
|
+
$psi.CreateNoWindow = $true
|
|
949
|
+
$psi.RedirectStandardInput = $true
|
|
950
|
+
$psi.RedirectStandardOutput = $true
|
|
951
|
+
$psi.RedirectStandardError = $true
|
|
952
|
+
$proc = [Diagnostics.Process]::Start($psi)
|
|
953
|
+
$proc.StandardInput.Close()
|
|
954
|
+
$errTask = $proc.StandardError.ReadToEndAsync() # read at the same time: a full error pipe would stop docker
|
|
955
|
+
$proc.StandardOutput.BaseStream.CopyTo($outStream)
|
|
956
|
+
$proc.WaitForExit()
|
|
957
|
+
return [pscustomobject]@{ ExitCode = $proc.ExitCode; Error = $errTask.Result }
|
|
958
|
+
} catch {
|
|
959
|
+
return [pscustomobject]@{ ExitCode = 127; Error = "$($_.Exception.Message)" }
|
|
960
|
+
} finally {
|
|
961
|
+
if ($proc) { $proc.Dispose() }
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
# Get-DockerText ARGS: the standard output of docker as text, or $null when it did not exit 0.
|
|
966
|
+
function Get-DockerText($dockerArgs) {
|
|
967
|
+
$mem = New-Object IO.MemoryStream
|
|
968
|
+
try {
|
|
969
|
+
$result = Invoke-DockerStream $dockerArgs $mem
|
|
970
|
+
if ($result.ExitCode -ne 0) { return $null }
|
|
971
|
+
return [Text.Encoding]::UTF8.GetString($mem.ToArray())
|
|
972
|
+
} finally {
|
|
973
|
+
$mem.Dispose()
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
# Get-LineKb TEXT WHICH: the number in front of the first (First) or last (Last) line of `du -sk`, or $null.
|
|
978
|
+
function Get-LineKb($text, $which) {
|
|
979
|
+
if ($null -eq $text) { return $null }
|
|
980
|
+
$lines = @("$text" -split "`r?`n" | Where-Object { $_ -match '^[0-9]+\b' })
|
|
981
|
+
if ($lines.Count -eq 0) { return $null }
|
|
982
|
+
$line = if ($which -eq 'First') { $lines[0] } else { $lines[$lines.Count - 1] }
|
|
983
|
+
return [int64](($line -split '\s+')[0])
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
# Get-FreeKb PATH: kilobytes free on the drive that holds PATH, or $null when that cannot be read.
|
|
987
|
+
function Get-FreeKb($path) {
|
|
988
|
+
try {
|
|
989
|
+
$name = if ($OnWindows) { [IO.Path]::GetPathRoot($path) } else { $path }
|
|
990
|
+
$drive = New-Object IO.DriveInfo($name)
|
|
991
|
+
return [int64]($drive.AvailableFreeSpace / 1024)
|
|
992
|
+
} catch {
|
|
993
|
+
return $null
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
# Read-GzipEnds FILE KEEP: decompress the whole file and return its first 512 bytes (Head), its last KEEP bytes (Tail) and its
|
|
998
|
+
# decompressed length (Length); $null when it is not a readable gzip. Reading all of it is what checks the gzip (its CRC).
|
|
999
|
+
function Read-GzipEnds($file, $keep) {
|
|
1000
|
+
$fs = $null
|
|
1001
|
+
$gz = $null
|
|
1002
|
+
try {
|
|
1003
|
+
$fs = [IO.File]::OpenRead($file)
|
|
1004
|
+
$gz = New-Object IO.Compression.GZipStream($fs, [IO.Compression.CompressionMode]::Decompress)
|
|
1005
|
+
$buffer = New-Object byte[] 1048576
|
|
1006
|
+
$head = New-Object byte[] 512
|
|
1007
|
+
$headLen = 0
|
|
1008
|
+
$tail = New-Object byte[] 0
|
|
1009
|
+
$total = [int64]0
|
|
1010
|
+
while ($true) {
|
|
1011
|
+
$n = $gz.Read($buffer, 0, $buffer.Length)
|
|
1012
|
+
if ($n -le 0) { break }
|
|
1013
|
+
if ($headLen -lt 512) {
|
|
1014
|
+
$take = [Math]::Min(512 - $headLen, $n)
|
|
1015
|
+
[Array]::Copy($buffer, 0, $head, $headLen, $take)
|
|
1016
|
+
$headLen += $take
|
|
1017
|
+
}
|
|
1018
|
+
$total += $n
|
|
1019
|
+
$all = New-Object byte[] ($tail.Length + $n)
|
|
1020
|
+
[Array]::Copy($tail, 0, $all, 0, $tail.Length)
|
|
1021
|
+
[Array]::Copy($buffer, 0, $all, $tail.Length, $n)
|
|
1022
|
+
if ($all.Length -gt $keep) {
|
|
1023
|
+
$tail = New-Object byte[] $keep
|
|
1024
|
+
[Array]::Copy($all, $all.Length - $keep, $tail, 0, $keep)
|
|
1025
|
+
} else {
|
|
1026
|
+
$tail = $all
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return [pscustomobject]@{ Head = $head; Tail = $tail; Length = $total }
|
|
1030
|
+
} catch {
|
|
1031
|
+
return $null
|
|
1032
|
+
} finally {
|
|
1033
|
+
if ($gz) { $gz.Dispose() }
|
|
1034
|
+
if ($fs) { $fs.Dispose() }
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
# Test-DumpWhole FILE: a readable gzip whose text ends with the line pg_dumpall writes last. An empty or cut dump fails here.
|
|
1039
|
+
function Test-DumpWhole($file) {
|
|
1040
|
+
$ends = Read-GzipEnds $file 4096
|
|
1041
|
+
if (-not $ends -or $ends.Length -eq 0) { return $false }
|
|
1042
|
+
$lines = @([Text.Encoding]::UTF8.GetString($ends.Tail) -split "`n" | Select-Object -Last 6)
|
|
1043
|
+
foreach ($line in $lines) {
|
|
1044
|
+
if ($line.TrimEnd("`r") -ceq $script:PgDumpEnd) { return $true }
|
|
1045
|
+
}
|
|
1046
|
+
return $false
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
# Test-ArchiveWhole FILE: a readable gzip that holds a whole tar archive: a first header with a name and a checksum that is
|
|
1050
|
+
# right, a length of whole 512-byte blocks, and the two empty blocks that end every archive. A gzip of something else, or of
|
|
1051
|
+
# half an archive, fails here (the exit status of tar alone proves nothing: GNU tar says nothing about a gzip that is no tar).
|
|
1052
|
+
# A real backup of the app files has at least the entry "./", even when the volume is empty.
|
|
1053
|
+
function Test-ArchiveWhole($file) {
|
|
1054
|
+
$ends = Read-GzipEnds $file 1024
|
|
1055
|
+
if (-not $ends -or $ends.Length -lt 1536 -or ($ends.Length % 512) -ne 0) { return $false }
|
|
1056
|
+
foreach ($b in $ends.Tail) { if ($b -ne 0) { return $false } }
|
|
1057
|
+
$head = $ends.Head
|
|
1058
|
+
if ($head[0] -eq 0) { return $false }
|
|
1059
|
+
$field = [Text.Encoding]::ASCII.GetString($head, 148, 8).Trim([char]0, ' ')
|
|
1060
|
+
try { $stored = [Convert]::ToInt64($field, 8) } catch { return $false }
|
|
1061
|
+
$sum = [int64]0
|
|
1062
|
+
for ($i = 0; $i -lt 512; $i++) {
|
|
1063
|
+
if ($i -ge 148 -and $i -lt 156) { $sum += 32 } else { $sum += $head[$i] }
|
|
1064
|
+
}
|
|
1065
|
+
return ($sum -eq $stored)
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
# Stop-Backup MESSAGE LINES: say what is wrong, remove what this backup wrote, return $false.
|
|
1069
|
+
function Stop-Backup($message, $lines = @()) {
|
|
1070
|
+
Write-Warn $message
|
|
1071
|
+
foreach ($line in @($lines)) { if ($line) { Write-Host $line } }
|
|
1072
|
+
if ($script:BackupDir -and (Test-Path -LiteralPath $script:BackupDir)) {
|
|
1073
|
+
Remove-Item -LiteralPath $script:BackupDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
1074
|
+
}
|
|
1075
|
+
$script:BackupDir = ''
|
|
1076
|
+
Write-Host 'Nothing was changed in GAP: the version, .env and your data are as they were.'
|
|
1077
|
+
return $false
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function Get-LastLines($text, $count) {
|
|
1081
|
+
(@("$text" -split "`r?`n" | Where-Object { $_ } | Select-Object -Last $count)) -join "`n"
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
# New-GapBackup: sets $script:BackupDir (<GapHome>\backups\<UTC time>) and returns $true, or returns $false after saying why and
|
|
1085
|
+
# leaving nothing behind. Starts only Postgres if it is not running (the dump goes through it); the Kernel is not needed, the
|
|
1086
|
+
# app files are read with a throw-away container of the old Kernel image.
|
|
1087
|
+
# Room: the backup is at most as big as what is on disk (gzip of a dump, tar of files that are often zips already), so the free
|
|
1088
|
+
# space of the backup folder has to cover the size of the database plus the size of gap_data, plus
|
|
1089
|
+
# $script:BackupSpaceMarginPercent for tar and gzip overhead. A size that cannot be measured is said and does not stop the
|
|
1090
|
+
# backup, which is checked when it is done.
|
|
1091
|
+
function New-GapBackup {
|
|
1092
|
+
$root = Join-Path $GapHome 'backups'
|
|
1093
|
+
if ((Get-SvcState 'postgres') -ne 'Healthy') {
|
|
1094
|
+
Write-Host 'Starting the database for the backup...'
|
|
1095
|
+
Invoke-Dc up -d --wait --wait-timeout 120 postgres *> $null
|
|
1096
|
+
if ($LASTEXITCODE -ne 0) {
|
|
1097
|
+
return (Stop-Backup 'The database did not start, so it cannot be backed up' @('Run: gap logs postgres'))
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (-not (Test-Path -LiteralPath $root)) {
|
|
1101
|
+
try { $null = New-Item -ItemType Directory -Path $root -ErrorAction Stop } catch {
|
|
1102
|
+
return (Stop-Backup "Cannot create $root" @("Check the permissions of $GapHome."))
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
$pgKb = Get-LineKb (Get-DockerText ((Get-DcArgs) + @('exec', '-T', 'postgres', 'du', '-sk', '/var/lib/postgresql/data'))) 'First'
|
|
1106
|
+
$dataKb = Get-LineKb (Get-DockerText ((Get-DcArgs) + @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'du', 'kernel', '-sk', '/data'))) 'Last'
|
|
1107
|
+
$freeKb = Get-FreeKb $root
|
|
1108
|
+
if ($null -ne $pgKb -and $null -ne $dataKb -and $null -ne $freeKb) {
|
|
1109
|
+
$needKb = [int64](($pgKb + $dataKb) * (100 + $script:BackupSpaceMarginPercent) / 100)
|
|
1110
|
+
if ($freeKb -lt $needKb) {
|
|
1111
|
+
$needMb = [int64][Math]::Floor(($needKb + 1023) / 1024)
|
|
1112
|
+
$freeMb = [int64][Math]::Floor($freeKb / 1024)
|
|
1113
|
+
return (Stop-Backup 'Not enough free space for the backup' @(
|
|
1114
|
+
"About $needMb MB are needed (the database and the app files), $freeMb MB are free in $root.",
|
|
1115
|
+
'Free some space, then run: gap update'))
|
|
1116
|
+
}
|
|
1117
|
+
} else {
|
|
1118
|
+
Write-Host '[?] The free space needed for the backup could not be measured; the backup is checked when it is done'
|
|
1119
|
+
}
|
|
1120
|
+
# Written under a ".partial" name and renamed only when everything has been checked: a Ctrl-C or a crash must not leave a
|
|
1121
|
+
# half written folder that looks like a backup.
|
|
1122
|
+
$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'", [Globalization.CultureInfo]::InvariantCulture)
|
|
1123
|
+
$final = Join-Path $root $stamp
|
|
1124
|
+
$dir = "$final.partial"
|
|
1125
|
+
try { $null = New-Item -ItemType Directory -Path $dir -ErrorAction Stop } catch {
|
|
1126
|
+
return (Stop-Backup "Cannot create $dir")
|
|
1127
|
+
}
|
|
1128
|
+
$script:BackupDir = $dir
|
|
1129
|
+
|
|
1130
|
+
Write-Host 'Backing up the database...'
|
|
1131
|
+
$dumpFile = Join-Path $dir 'db.sql.gz'
|
|
1132
|
+
$result = $null
|
|
1133
|
+
$fs = [IO.File]::Create($dumpFile)
|
|
1134
|
+
try {
|
|
1135
|
+
$gz = New-Object IO.Compression.GZipStream($fs, [IO.Compression.CompressionMode]::Compress)
|
|
1136
|
+
try { $result = Invoke-DockerStream ((Get-DcArgs) + @('exec', '-T', 'postgres', 'pg_dumpall', '-U', 'postgres')) $gz }
|
|
1137
|
+
finally { $gz.Dispose() }
|
|
1138
|
+
} finally {
|
|
1139
|
+
$fs.Dispose()
|
|
1140
|
+
}
|
|
1141
|
+
if ($result.ExitCode -ne 0) {
|
|
1142
|
+
return (Stop-Backup 'The backup of the database failed' @((Get-LastLines $result.Error 3)))
|
|
1143
|
+
}
|
|
1144
|
+
if (-not (Test-DumpWhole $dumpFile)) { return (Stop-Backup 'The backup of the database is empty or cut short') }
|
|
1145
|
+
|
|
1146
|
+
Write-Host "Backing up the app files (volume ${GapProject}_data)..."
|
|
1147
|
+
$dataFile = Join-Path $dir 'data.tar.gz'
|
|
1148
|
+
$fs = [IO.File]::Create($dataFile)
|
|
1149
|
+
try {
|
|
1150
|
+
$result = Invoke-DockerStream ((Get-DcArgs) + @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'tar', 'kernel', '-czf', '-', '-C', '/data', '.')) $fs
|
|
1151
|
+
} finally {
|
|
1152
|
+
$fs.Dispose()
|
|
1153
|
+
}
|
|
1154
|
+
if ($result.ExitCode -ne 0) {
|
|
1155
|
+
# tar says 1 when a file changed while it was read: the Kernel was writing. Nothing is wrong with the data.
|
|
1156
|
+
return (Stop-Backup 'The backup of the app files failed' @((Get-LastLines $result.Error 3), 'If GAP was busy, run: gap stop and then: gap update'))
|
|
1157
|
+
}
|
|
1158
|
+
if (-not (Test-ArchiveWhole $dataFile)) { return (Stop-Backup 'The backup of the app files is empty or unreadable') }
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
[IO.File]::Copy((Get-EnvPath), (Join-Path $dir '.env'), $true)
|
|
1162
|
+
[IO.File]::Copy((Join-Path $GapHome 'docker-compose.yml'), (Join-Path $dir 'docker-compose.yml'), $true)
|
|
1163
|
+
} catch {
|
|
1164
|
+
return (Stop-Backup 'Could not copy .env and the compose file into the backup')
|
|
1165
|
+
}
|
|
1166
|
+
try { [IO.Directory]::Move($dir, $final) } catch {
|
|
1167
|
+
return (Stop-Backup "Could not finish the backup folder $final")
|
|
1168
|
+
}
|
|
1169
|
+
$script:BackupDir = $final
|
|
1170
|
+
return $true
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
# Copy-FileReplacing SRC DEST: replace DEST in one step, so that nothing reads half a file. Throws when it cannot.
|
|
1174
|
+
function Copy-FileReplacing($src, $dest) {
|
|
1175
|
+
$temp = "$dest.new"
|
|
1176
|
+
[IO.File]::Copy($src, $temp, $true)
|
|
1177
|
+
# [NullString]::Value, not $null: PowerShell hands a $null to a .NET string parameter as an empty string, which Replace refuses
|
|
1178
|
+
if (Test-Path -LiteralPath $dest) { [IO.File]::Replace($temp, $dest, [NullString]::Value) } else { [IO.File]::Move($temp, $dest) }
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
# Test-GapHealth [TRIES] [PAUSE]: the Admin UI answers on /health, /system/health and / (the path the user will take), for up
|
|
1182
|
+
# to TRIES x PAUSE seconds each (15 x 2: about 30 seconds). Returns $false after saying which one did not. (install.ps1 does
|
|
1183
|
+
# the same inline; this is `gap update`'s.)
|
|
1184
|
+
function Test-GapHealth($tries = 15, $pause = 2) {
|
|
1185
|
+
foreach ($path in '/health', '/system/health', '/') {
|
|
1186
|
+
$n = 0
|
|
1187
|
+
while (-not (Test-HttpOk $path)) {
|
|
1188
|
+
$n++
|
|
1189
|
+
if ($n -ge $tries) {
|
|
1190
|
+
Write-Warn "Health check failed: $(Get-UiUrl)$path"
|
|
1191
|
+
Write-Host 'Run: gap logs kernel'
|
|
1192
|
+
return $false
|
|
1193
|
+
}
|
|
1194
|
+
Start-Sleep -Seconds $pause
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return $true
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
# Write-FilesBackHelp BACKUP: the two lines that put the old .env and compose file back (the first two of the restore).
|
|
1201
|
+
function Write-FilesBackHelp($backup) {
|
|
1202
|
+
Write-Host (' Copy-Item "{0}" "{1}"' -f (Join-Path $backup '.env'), (Get-EnvPath))
|
|
1203
|
+
Write-Host (' Copy-Item "{0}" "{1}"' -f (Join-Path $backup 'docker-compose.yml'), (Join-Path $GapHome 'docker-compose.yml'))
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
# Write-RestoreHelp BACKUP OLDVERSION: how to go back to OLDVERSION with the data of the backup. The same order as the bash text
|
|
1207
|
+
# (spike S4 ran it for real): no ON_ERROR_STOP (psql would stop on the one error that is expected), restore only into an empty
|
|
1208
|
+
# database volume with only Postgres running (a restore over data doubles the rows of tables without a primary key), and the
|
|
1209
|
+
# password after the restore is the one of the backup, which is the one in the backup's .env. In PowerShell: the dump goes into
|
|
1210
|
+
# the container with `docker cp` and is unzipped there (no pipe of a binary file), and the app files are read from the backup
|
|
1211
|
+
# folder, mounted into a throw-away Kernel container. Nothing is run; the paths are written in full and quoted.
|
|
1212
|
+
function Write-RestoreHelp($backup, $oldVersion) {
|
|
1213
|
+
$dcx = 'docker compose -p {0} --project-directory "{1}" -f "{2}" --env-file "{3}"' -f $GapProject, $GapHome, (Join-Path $GapHome 'docker-compose.yml'), (Get-EnvPath)
|
|
1214
|
+
Write-Host ''
|
|
1215
|
+
Write-Host "To go back to GAP $oldVersion with the data of the backup (this erases the current database and app files first, because"
|
|
1216
|
+
Write-Host 'the new version may already have changed them; a restore over existing data would duplicate rows). In PowerShell:'
|
|
1217
|
+
Write-FilesBackHelp $backup
|
|
1218
|
+
Write-Host " $dcx down -v"
|
|
1219
|
+
Write-Host " $dcx up -d --wait postgres"
|
|
1220
|
+
Write-Host " `$pg = $dcx ps -q postgres"
|
|
1221
|
+
Write-Host (' docker cp "{0}" "${{pg}}:/tmp/db.sql.gz"' -f (Join-Path $backup 'db.sql.gz'))
|
|
1222
|
+
Write-Host " $dcx exec -T postgres sh -c `"gunzip -c /tmp/db.sql.gz | psql -U postgres -q`""
|
|
1223
|
+
Write-Host (' {0} run --rm --no-deps -T -v "{1}:/backup:ro" --entrypoint tar kernel -xzf /backup/data.tar.gz -C /data' -f $dcx, $backup)
|
|
1224
|
+
Write-Host ' gap start'
|
|
1225
|
+
Write-Host "Do not add psql's ON_ERROR_STOP: the restore prints exactly one error, role `"postgres`" already exists, and that is"
|
|
1226
|
+
Write-Host "expected (and a few small set_config tables, which are normal). The images of $oldVersion are still on this machine."
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
# Write-UpdateFailed OLD NEW BACKUP: the new version did not come up. Nothing is rolled back (the Kernel may already have
|
|
1230
|
+
# migrated the database, and a migration is not undone): say where the backup is, what .env says now, and how to go back.
|
|
1231
|
+
function Write-UpdateFailed($old, $new, $backup) {
|
|
1232
|
+
Write-Warn "The update to GAP $new did not finish, and GAP $old was not restored automatically"
|
|
1233
|
+
Write-Host " The backup made before the update: $backup"
|
|
1234
|
+
Write-Host " .env now says GAP_VERSION=$(Get-EnvValue 'GAP_VERSION'). Nothing was rolled back and nothing was deleted."
|
|
1235
|
+
Write-Host ' First look at what the new version says: gap logs kernel'
|
|
1236
|
+
Write-RestoreHelp $backup $old
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
# Write-UpdateNotApplied OLD NEW BACKUP [REASON]: the new .env or compose file could not be written. The stack was not touched (the old
|
|
1240
|
+
# version still runs as before); .env and the compose file may be half moved, and the backup holds the old ones.
|
|
1241
|
+
function Write-UpdateNotApplied($old, $new, $backup, $reason = '') {
|
|
1242
|
+
Write-Warn "The update to GAP $new could not be applied: the files in $GapHome could not be written"
|
|
1243
|
+
if ($reason) { Write-Host " $reason" }
|
|
1244
|
+
Write-Host " The running stack was not touched; GAP $old runs as before. The backup made before: $backup"
|
|
1245
|
+
Write-Host " .env now says GAP_VERSION=$(Get-EnvValue 'GAP_VERSION'). To put the old .env and compose file back:"
|
|
1246
|
+
Write-FilesBackHelp $backup
|
|
1247
|
+
Write-Host " Check the permissions of $GapHome, then run: gap update"
|
|
1248
|
+
}
|