Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cef2cca5d | ||
|
|
48c8b7e609 | ||
|
|
062099ae5b | ||
|
|
0e7c33bee2 | ||
|
|
5a082916f8 | ||
|
|
0eac05b450 | ||
|
|
7bbbb53e69 | ||
|
|
eb66e6a347 | ||
|
|
36122bc29b | ||
|
|
6d035dc4fa | ||
|
|
ea17a4e0ff | ||
|
|
a972ea204e | ||
|
|
2551643aa1 | ||
|
|
b939b85ecb | ||
|
|
4581fddd8d | ||
|
|
e13b12c492 | ||
|
|
f4b5309267 | ||
|
|
9e985fef5b | ||
|
|
5ecdcd7201 | ||
|
|
f88596f90c | ||
|
|
602c3c165f | ||
|
|
d25a38fe62 | ||
|
|
d92fb68b22 | ||
|
|
2bca0b3cc9 | ||
|
|
2fc4b1f3d4 | ||
|
|
c6ef41a951 | ||
|
|
35c9235d17 | ||
|
|
58c531d831 | ||
|
|
b39f1a8d79 | ||
|
|
d91a2745f0 | ||
|
|
ad6c028cff | ||
|
|
269e32d5ee | ||
|
|
7378060e5c | ||
|
|
d52fe4740e | ||
|
|
67eab1ba89 | ||
|
|
9e95d9a721 | ||
|
|
9be31a019f | ||
|
|
145e511f05 | ||
|
|
a0c8f4857f | ||
|
|
0eefa38f77 | ||
|
|
32db693198 | ||
|
|
d761942d0b | ||
|
|
2afe505ef4 | ||
|
|
730d1e783c | ||
|
|
c4321b9968 | ||
|
|
99f5606e2e | ||
|
|
a1a43e8c28 | ||
|
|
ea293b335e | ||
|
|
a623526cb7 | ||
|
|
39aed6083b | ||
|
|
05b2864d32 | ||
|
|
8c5dd5eadb | ||
|
|
b345337ddc | ||
|
|
b8afe34fbb | ||
|
|
7f21dc4610 | ||
|
|
ac76a81ad2 | ||
|
|
5f9909e109 | ||
|
|
31a5c5418c | ||
|
|
cc1e1ef184 | ||
|
|
1402f7d9cd | ||
|
|
6594977cc3 | ||
|
|
b98470f327 | ||
|
|
35307612fc | ||
|
|
8b381912a8 | ||
|
|
ab749c630c | ||
|
|
1cac42d986 | ||
|
|
f73b51f912 | ||
|
|
733cd5ba5e | ||
|
|
8877330705 | ||
|
|
8d8bcf3748 | ||
|
|
7828c9b2a1 | ||
|
|
09afc34c0e | ||
|
|
2477a94b32 | ||
|
|
bbb5cc250b | ||
|
|
33fab95a73 | ||
|
|
755cfec19e | ||
|
|
5711c4dd5a | ||
|
|
a6374d1ccd | ||
|
|
78afe89752 | ||
|
|
70c6777b5e | ||
|
|
7c5b0b7da7 | ||
|
|
472965ec40 | ||
|
|
610cad0e0e | ||
|
|
72ab768b94 | ||
|
|
5063c76a06 | ||
|
|
607c9eccbf | ||
|
|
e8f7a18c98 | ||
|
|
a00cd1dd43 | ||
|
|
b6a4c7fe15 | ||
|
|
d699acab0b | ||
|
|
5f6d72c686 | ||
|
|
50209794d7 | ||
|
|
5515b54d53 | ||
|
|
a0b4c09772 | ||
|
|
0389ee789a | ||
|
|
26a7d92b0b | ||
|
|
e02150e634 | ||
|
|
3e6f0c0703 | ||
|
|
1b05065eb0 | ||
|
|
8f24dc378d | ||
|
|
f4337dc8c7 | ||
|
|
fd4b47788b | ||
|
|
74003c19dc | ||
|
|
a11fc0b921 | ||
|
|
5811f55b08 | ||
|
|
d1374536d8 | ||
|
|
04684e798a | ||
|
|
8df5306ebd | ||
|
|
ab632646ed | ||
|
|
43d1d59e00 | ||
|
|
149170195d | ||
|
|
e0a2d2138e | ||
|
|
b10718d616 | ||
|
|
3ec0aafa36 | ||
|
|
6a90e4f837 | ||
|
|
ccdb1f6271 | ||
|
|
3d5661ed87 | ||
|
|
6404865bd5 | ||
|
|
e28b84ed67 | ||
|
|
fc12b2e36b | ||
|
|
34908c4093 | ||
|
|
5a56a3f3dc | ||
|
|
bee294a373 | ||
|
|
224c486c58 | ||
|
|
2a952d678d | ||
|
|
3a6f86a711 | ||
|
|
e85db0fb8b | ||
|
|
0cbddf21ba | ||
|
|
e2e588c324 | ||
|
|
6302a70316 | ||
|
|
c41b606795 | ||
|
|
d924c15e79 | ||
|
|
1530b3551d | ||
|
|
32f9b4a8f6 | ||
|
|
6b8394fe31 | ||
|
|
a9f2cb5405 | ||
|
|
7c974c005c | ||
|
|
b3a941c469 | ||
|
|
4682025f0c | ||
|
|
e5b6728fa9 | ||
|
|
fad01ed776 | ||
|
|
d5bd079593 | ||
|
|
5fc540af73 | ||
|
|
1058f34c52 | ||
|
|
3d3eb8feb9 | ||
|
|
125cb3007f | ||
|
|
fa9762f345 | ||
|
|
d24b946909 | ||
|
|
bdb652b772 | ||
|
|
aaa0c8f14d | ||
|
|
641173d0fd | ||
|
|
ebe4617c00 | ||
|
|
a4bdcff7dc | ||
|
|
d082e8db27 | ||
|
|
a3ec78d30c | ||
|
|
2b7a331896 | ||
|
|
79d138867a | ||
|
|
9712467844 | ||
|
|
347312d8f7 | ||
|
|
5065de5ac3 | ||
|
|
a5aa42843c | ||
|
|
ecb6446c31 | ||
|
|
ca21e94436 | ||
|
|
a3ffb577ae | ||
|
|
c711abe98e | ||
|
|
b8dcd80ae1 | ||
|
|
0360a223a4 | ||
|
|
53ccc753e3 | ||
|
|
9f9bb553c4 | ||
|
|
a807c99daf | ||
|
|
86f164d60c | ||
|
|
dee788c581 | ||
|
|
7d3a170b9e | ||
|
|
467a200ca4 | ||
|
|
cf2c096c4f | ||
|
|
e64b766ca3 | ||
|
|
cd82c8e4ec | ||
|
|
25aec55eb2 | ||
|
|
c83e0750c4 | ||
|
|
19a41432ac | ||
|
|
7314f5069b | ||
|
|
5f58828e49 | ||
|
|
9d63ea91e2 | ||
|
|
a0723f9db8 | ||
|
|
2a65e506a6 | ||
|
|
a100b86ff2 | ||
|
|
bdd3282c49 | ||
|
|
f6a2c1037e | ||
|
|
3515541320 | ||
|
|
369c12c1b2 | ||
|
|
88b599e98f | ||
|
|
4671d2f221 | ||
|
|
8f487ac666 | ||
|
|
6e22b29770 | ||
|
|
ad3ba69ca5 | ||
|
|
fd7b462f3d | ||
|
|
efa8ac7948 | ||
|
|
0dc122cb2e | ||
|
|
c8ae16d13f | ||
|
|
dd0d8ac533 | ||
|
|
d0315c58ec | ||
|
|
43d8b3fabf | ||
|
|
e78c7fe020 | ||
|
|
2ba68de777 | ||
|
|
afa0c773ad | ||
|
|
351376af7f | ||
|
|
91e7747226 | ||
|
|
84a76baa8e | ||
|
|
c603393827 | ||
|
|
ce6c049dbc | ||
|
|
bccef24bf7 | ||
|
|
e543aaa6f9 | ||
|
|
720dd3714f | ||
|
|
af5fd2acc3 | ||
|
|
2343f60f04 | ||
|
|
5f102aa36c | ||
|
|
1b392caa0f | ||
|
|
8bca7e0c57 | ||
|
|
475b864e7f | ||
|
|
d04e39cc65 | ||
|
|
b7789be4af | ||
|
|
97a7e55b96 | ||
|
|
ff9819e222 | ||
|
|
355d6d099f | ||
|
|
da0bc3ecd9 | ||
|
|
db2030e9c0 | ||
|
|
34cb0be9a2 | ||
|
|
ca275cbc8d | ||
|
|
c28362013f | ||
|
|
b2d1362b8a | ||
|
|
24a1f24e48 | ||
|
|
e1915c7dc0 | ||
|
|
89bb92dc44 | ||
|
|
42d2e55bcd | ||
|
|
f57e46c7c4 | ||
|
|
9a68105260 | ||
|
|
299a767b1f | ||
|
|
fc6815ae70 | ||
|
|
456b2ddefa | ||
|
|
9039065ee0 | ||
|
|
620f7b8064 | ||
|
|
3cd04b405e | ||
|
|
8057624259 | ||
|
|
750d0630e0 | ||
|
|
c23172af37 | ||
|
|
458b24b5f8 | ||
|
|
4dc7a7bd7b | ||
|
|
6312baa34e | ||
|
|
5f56805162 | ||
|
|
7242be8be6 | ||
|
|
e419d45930 | ||
|
|
7c6fb89b82 | ||
|
|
a2439b78f5 | ||
|
|
14a796c427 | ||
|
|
53729372a7 | ||
|
|
08f643e619 | ||
|
|
2bb59c0b10 | ||
|
|
e1ad058990 | ||
|
|
4e23684096 | ||
|
|
c5e67bdc67 | ||
|
|
07d2f4dbc5 | ||
|
|
ca4fe9d08a | ||
|
|
2d2402c822 | ||
|
|
9195037213 | ||
|
|
9e31eebe26 | ||
|
|
ebbe4e36d8 | ||
|
|
aca8796178 | ||
|
|
27809a95ef | ||
|
|
211f9bf1f3 | ||
|
|
df39657d6d | ||
|
|
79f5bdfb40 | ||
|
|
5c7f0bdb05 | ||
|
|
5060cd8bc0 | ||
|
|
d7a6a89df3 | ||
|
|
7dc7292d53 |
No files matched your search
@@ -1,2 +1,7 @@
|
||||
# Auto detect text files and normalize end of lines to LF (\n)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Make.bat has always been stored with CRLF; the blanket eol=lf above would
|
||||
# silently rewrite the whole file the first time anything touches it.
|
||||
Make.bat -text
|
||||
pkg/win32/*.bat text eol=crlf
|
||||
@@ -19,6 +19,29 @@
|
||||
# are predictable, so this needs neither a CLI nor authentication, and both
|
||||
# curl --fail and Invoke-WebRequest fail on a 404 rather than writing an error
|
||||
# page to disk and failing later at the unpack.
|
||||
#
|
||||
# What gets downloaded is decided by pkg/pgbuild-deps.lock rather than by the
|
||||
# rolling -latest tags, whose assets pgbuild replaces in place on every
|
||||
# rebuild. Building from those means that what is Authenticode signed on
|
||||
# Windows and Developer ID signed and notarised on macOS is whatever pgbuild
|
||||
# happened to be publishing that morning, which is neither reproducible nor
|
||||
# something anybody reviewed. The lock names a per-version tag and the SHA-256
|
||||
# of its archive, and an archive that does not hash to the recorded digest, or
|
||||
# that has no lock entry at all, fails the build rather than being unpacked
|
||||
# unverified. pgbuild publishes a .sha256 beside each archive, which is
|
||||
# deliberately not consulted: whoever rebuilds an archive rebuilds that too, so
|
||||
# it is a convenience rather than a source of trust, and the lock is the only
|
||||
# thing here that pins anything.
|
||||
#
|
||||
# Being out of date is a different thing from being wrong, so once an archive
|
||||
# has been verified, pgbuild's releases are consulted to see whether a newer
|
||||
# build of that dependency exists, and if one does the job carries a warning
|
||||
# naming it. It stays a warning, because the build goes on to ship the pinned
|
||||
# version and a dependency published overnight is no reason to turn pgAdmin
|
||||
# red, and the check is best effort, so an API that will not answer leaves a
|
||||
# build whose downloads are already verified alone.
|
||||
# tools/refresh_pgbuild_lock.py is what turns such a warning into a reviewed
|
||||
# commit.
|
||||
|
||||
name: Install pgbuild dependencies
|
||||
description: Download and unpack the PostgreSQL build dependencies from pgadmin-org/pgbuild
|
||||
@@ -50,20 +73,129 @@ runs:
|
||||
env:
|
||||
PGBUILD_ARCH: ${{ inputs.arch }}
|
||||
POSTGRES_MAJOR: ${{ inputs.postgres-major }}
|
||||
# Only so that the out-of-date check does not run into the
|
||||
# unauthenticated rate limit, which is shared with everything else
|
||||
# coming from a hosted runner's address and would leave the warning
|
||||
# firing only sometimes. Nothing else here needs authentication,
|
||||
# pgbuild being public.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE=https://github.com/pgadmin-org/pgbuild/releases/download
|
||||
RELEASES=https://api.github.com/repos/pgadmin-org/pgbuild/releases
|
||||
|
||||
# GITHUB_ACTION_PATH rather than the workspace, so that the lock is
|
||||
# found even where a workflow checks the repository out somewhere
|
||||
# other than the default directory.
|
||||
LOCK="${GITHUB_ACTION_PATH}/../../../pkg/pgbuild-deps.lock"
|
||||
|
||||
# The hosted macOS images have shasum, which comes with the system
|
||||
# perl, but not sha256sum, whereas a Linux-alike self-hosted runner is
|
||||
# the other way about, so take whichever is there.
|
||||
if command -v shasum > /dev/null; then
|
||||
sha256() { shasum -a 256 "$1" | cut -d' ' -f1; }
|
||||
else
|
||||
sha256() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
fi
|
||||
|
||||
# Column 2 of a lock line is the tag to fetch and column 3 the digest
|
||||
# its archive must hash to. Trailing comments are stripped first,
|
||||
# which also rebuilds awk's idea of the fields.
|
||||
lock_field() {
|
||||
awk -v key="$1" -v col="$2" '
|
||||
{ sub(/#.*/, "") }
|
||||
$1 == key { print $col; exit }
|
||||
' "${LOCK}"
|
||||
}
|
||||
|
||||
# pgbuild's tags, newest first, for the out-of-date check below. This
|
||||
# is deliberately best effort: a build whose downloads have been
|
||||
# verified must not fail because GitHub would not say what else has
|
||||
# been published since.
|
||||
TAGS="${RUNNER_TEMP}/pgbuild-tags.txt"
|
||||
: > "${TAGS}"
|
||||
|
||||
if ! curl --fail --silent --location --max-time 30 \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
${GH_TOKEN:+-H "Authorization: Bearer ${GH_TOKEN}"} \
|
||||
"${RELEASES}?per_page=100" \
|
||||
| grep -o '"tag_name": *"[^"]*"' \
|
||||
| sed 's/.*"\(.*\)"/\1/' > "${TAGS}"; then
|
||||
echo "Could not list pgbuild's releases, so this build will not"
|
||||
echo "report whether its pinned dependencies are still current."
|
||||
fi
|
||||
|
||||
sudo mkdir -p /opt/pgbuild
|
||||
sudo chown "$(id -u):$(id -g)" /opt/pgbuild
|
||||
|
||||
for PKG in openssl krb5 zstd lz4 "postgresql-${POSTGRES_MAJOR}"; do
|
||||
TAG="${PKG}-macos-${PGBUILD_ARCH}-latest"
|
||||
echo "Fetching ${TAG}..."
|
||||
KEY="${PKG}-macos-${PGBUILD_ARCH}"
|
||||
TAG=$(lock_field "${KEY}" 2)
|
||||
EXPECTED=$(lock_field "${KEY}" 3)
|
||||
|
||||
if [ -z "${TAG}" ] || [ -z "${EXPECTED}" ]; then
|
||||
echo "ERROR: ${KEY} has no entry in pkg/pgbuild-deps.lock"
|
||||
echo
|
||||
echo "An unpinned dependency must not reach a build we sign,"
|
||||
echo "so this is a failure rather than a warning. Add it with:"
|
||||
echo
|
||||
echo " tools/refresh_pgbuild_lock.py ${PKG%%-*}"
|
||||
echo
|
||||
echo "If this is a PostgreSQL major the lock has never seen,"
|
||||
echo "add it to POSTGRES_MAJORS in that script first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="${TAG}.tar.gz"
|
||||
echo "Fetching ${ARCHIVE}..."
|
||||
curl --fail --silent --show-error --location \
|
||||
-o "${RUNNER_TEMP}/${TAG}.tar.gz" \
|
||||
"${BASE}/${TAG}/${TAG}.tar.gz"
|
||||
tar -xzf "${RUNNER_TEMP}/${TAG}.tar.gz" -C /opt/pgbuild
|
||||
-o "${RUNNER_TEMP}/${ARCHIVE}" "${BASE}/${TAG}/${ARCHIVE}"
|
||||
|
||||
ACTUAL=$(sha256 "${RUNNER_TEMP}/${ARCHIVE}")
|
||||
|
||||
if [ "${ACTUAL}" != "${EXPECTED}" ]; then
|
||||
echo "ERROR: checksum mismatch for ${ARCHIVE}"
|
||||
echo " expected: ${EXPECTED}"
|
||||
echo " actual: ${ACTUAL}"
|
||||
echo
|
||||
echo "The bytes behind ${TAG} are not the"
|
||||
echo "ones that were reviewed when the lock was written."
|
||||
echo "Usually that means pgbuild rebuilt the tag in place,"
|
||||
echo "which it does for PostgreSQL whenever a new minor comes"
|
||||
echo "out, that tag carrying only the major. Satisfy yourself"
|
||||
echo "that a rebuild is what happened before re-running,"
|
||||
echo "because this is also the one check standing between a"
|
||||
echo "tampered dependency and a signed pgAdmin build, and then"
|
||||
echo "record the new archive, read the diff and commit it:"
|
||||
echo
|
||||
echo " tools/refresh_pgbuild_lock.py ${PKG%%-*}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified ${ARCHIVE} (sha256 ${ACTUAL})"
|
||||
|
||||
# The first tag of the right shape is the newest, the listing
|
||||
# coming back in reverse chronological order. The rolling tag is
|
||||
# skipped because it names no version.
|
||||
NEWEST=""
|
||||
|
||||
while read -r CANDIDATE; do
|
||||
case "${CANDIDATE}" in
|
||||
*-latest)
|
||||
continue
|
||||
;;
|
||||
${PKG}*-macos-${PGBUILD_ARCH})
|
||||
NEWEST="${CANDIDATE}"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done < "${TAGS}"
|
||||
|
||||
if [ -n "${NEWEST}" ] && [ "${NEWEST}" != "${TAG}" ]; then
|
||||
echo "::warning title=pgbuild dependency out of date::${KEY} is pinned to ${TAG}, but pgbuild has since published ${NEWEST}. This build is using the pinned version, which is what the lock file is for, and nothing is broken. To move to the newer build, run tools/refresh_pgbuild_lock.py ${PKG%%-*}, review the change to pkg/pgbuild-deps.lock and commit it."
|
||||
fi
|
||||
|
||||
tar -xzf "${RUNNER_TEMP}/${ARCHIVE}" -C /opt/pgbuild
|
||||
done
|
||||
|
||||
ls -l /opt/pgbuild
|
||||
@@ -76,20 +208,127 @@ runs:
|
||||
env:
|
||||
PGBUILD_ARCH: ${{ inputs.arch }}
|
||||
POSTGRES_MAJOR: ${{ inputs.postgres-major }}
|
||||
# See the macOS step: this is for the out-of-date check's rate limit
|
||||
# alone, and the downloads themselves need no authentication.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Without this, Invoke-WebRequest spends most of a 90 MB download
|
||||
# rendering a progress bar into a log nobody will read.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$base = 'https://github.com/pgadmin-org/pgbuild/releases/download'
|
||||
$releases = 'https://api.github.com/repos/pgadmin-org/pgbuild/releases'
|
||||
|
||||
# GITHUB_ACTION_PATH rather than the workspace, so that the lock is
|
||||
# found even where a workflow checks the repository out somewhere
|
||||
# other than the default directory.
|
||||
$lock = Join-Path $env:GITHUB_ACTION_PATH '..\..\..\pkg\pgbuild-deps.lock'
|
||||
|
||||
# Column 2 of a lock line is the tag to fetch and column 3 the digest
|
||||
# its archive must hash to; anything after a # is a comment.
|
||||
function Get-LockEntry($key) {
|
||||
foreach ($line in Get-Content $lock) {
|
||||
$text = ($line -split '#', 2)[0].Trim()
|
||||
|
||||
if ($text -eq '') { continue }
|
||||
|
||||
$fields = $text -split '\s+'
|
||||
|
||||
if ($fields.Count -eq 3 -and $fields[0] -eq $key) {
|
||||
return @{ Tag = $fields[1]; Digest = $fields[2] }
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# pgbuild's tags, newest first, for the out-of-date check below. This
|
||||
# is deliberately best effort: a build whose downloads have been
|
||||
# verified must not fail because GitHub would not say what else has
|
||||
# been published since.
|
||||
$tags = @()
|
||||
|
||||
try {
|
||||
$headers = @{ 'Accept' = 'application/vnd.github+json' }
|
||||
|
||||
if ($env:GH_TOKEN) {
|
||||
$headers['Authorization'] = "Bearer $env:GH_TOKEN"
|
||||
}
|
||||
|
||||
$tags = (Invoke-RestMethod -Uri "${releases}?per_page=100" -Headers $headers -UseBasicParsing) | ForEach-Object { $_.tag_name }
|
||||
} catch {
|
||||
Write-Output "Could not list pgbuild's releases, so this build will not"
|
||||
Write-Output "report whether its pinned dependencies are still current."
|
||||
}
|
||||
|
||||
# The first tag of the right shape is the newest, the listing coming
|
||||
# back in reverse chronological order. The rolling tag is skipped
|
||||
# because it names no version.
|
||||
function Get-NewestTag($pattern) {
|
||||
foreach ($tag in $tags) {
|
||||
if ($tag -like '*-latest') { continue }
|
||||
|
||||
if ($tag -like $pattern) { return $tag }
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$depsDir = Join-Path $env:RUNNER_TEMP 'pgbuild-deps'
|
||||
New-Item -ItemType Directory -Force -Path $depsDir | Out-Null
|
||||
|
||||
$base = 'https://github.com/pgadmin-org/pgbuild/releases/download'
|
||||
foreach ($pkg in @("postgresql-$env:POSTGRES_MAJOR", 'krb5')) {
|
||||
$tag = "$pkg-windows-$env:PGBUILD_ARCH-latest"
|
||||
$zip = Join-Path $env:RUNNER_TEMP "$tag.zip"
|
||||
Write-Output "Fetching $tag..."
|
||||
Invoke-WebRequest -Uri "$base/$tag/$tag.zip" -OutFile $zip -UseBasicParsing
|
||||
$key = "$pkg-windows-$env:PGBUILD_ARCH"
|
||||
$pin = Get-LockEntry $key
|
||||
$family = ($pkg -split '-')[0]
|
||||
|
||||
if (-not $pin) {
|
||||
Write-Output "ERROR: $key has no entry in pkg/pgbuild-deps.lock"
|
||||
Write-Output ""
|
||||
Write-Output "An unpinned dependency must not reach a build we sign,"
|
||||
Write-Output "so this is a failure rather than a warning. Add it with:"
|
||||
Write-Output ""
|
||||
Write-Output " tools/refresh_pgbuild_lock.py $family"
|
||||
Write-Output ""
|
||||
Write-Output "If this is a PostgreSQL major the lock has never seen,"
|
||||
Write-Output "add it to POSTGRES_MAJORS in that script first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$archive = "$($pin.Tag).zip"
|
||||
$zip = Join-Path $env:RUNNER_TEMP $archive
|
||||
Write-Output "Fetching $archive..."
|
||||
Invoke-WebRequest -Uri "$base/$($pin.Tag)/$archive" -OutFile $zip -UseBasicParsing
|
||||
|
||||
$actual = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLower()
|
||||
|
||||
if ($actual -ne $pin.Digest) {
|
||||
Write-Output "ERROR: checksum mismatch for $archive"
|
||||
Write-Output " expected: $($pin.Digest)"
|
||||
Write-Output " actual: $actual"
|
||||
Write-Output ""
|
||||
Write-Output "The bytes behind $($pin.Tag) are not the"
|
||||
Write-Output "ones that were reviewed when the lock was written."
|
||||
Write-Output "Usually that means pgbuild rebuilt the tag in place,"
|
||||
Write-Output "which it does for PostgreSQL whenever a new minor comes"
|
||||
Write-Output "out, that tag carrying only the major. Satisfy yourself"
|
||||
Write-Output "that a rebuild is what happened before re-running,"
|
||||
Write-Output "because this is also the one check standing between a"
|
||||
Write-Output "tampered dependency and a signed pgAdmin build, and then"
|
||||
Write-Output "record the new archive, read the diff and commit it:"
|
||||
Write-Output ""
|
||||
Write-Output " tools/refresh_pgbuild_lock.py $family"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output "Verified $archive (sha256 $actual)"
|
||||
|
||||
$newest = Get-NewestTag "$pkg*-windows-$env:PGBUILD_ARCH"
|
||||
|
||||
if ($newest -and $newest -ne $pin.Tag) {
|
||||
Write-Output "::warning title=pgbuild dependency out of date::$key is pinned to $($pin.Tag), but pgbuild has since published $newest. This build is using the pinned version, which is what the lock file is for, and nothing is broken. To move to the newer build, run tools/refresh_pgbuild_lock.py $family, review the change to pkg/pgbuild-deps.lock and commit it."
|
||||
}
|
||||
|
||||
Expand-Archive -Path $zip -DestinationPath $depsDir -Force
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
# Install pgAdmin from a published tree, exactly as the README tells a user to.
|
||||
#
|
||||
# Everything else in this pipeline checks that files exist, that indexes parse
|
||||
# and that signatures validate. All three can be true of a repository apt still
|
||||
# refuses, and two bugs found during the migration were of precisely that
|
||||
# shape: packages one directory too deep, and noarch RPMs filed where no dnf
|
||||
# client looks. Neither failed a build.
|
||||
#
|
||||
# So this does not inspect anything. It follows the instructions in the
|
||||
# repository README, in a clean container, and installs the package. No
|
||||
# --allow-unauthenticated, no --nogpgcheck, no pinning: if the signature is
|
||||
# wrong or the architecture is missing from Release, the install fails, which
|
||||
# is the point.
|
||||
#
|
||||
# It runs against a release candidate on the development server, which serves
|
||||
# its tree the moment it is written. The download server sits behind an hourly
|
||||
# sync to the mirrors, so testing there would mostly measure the sync.
|
||||
|
||||
name: Acceptance test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base-url:
|
||||
description: The tree to install from, without a trailing slash
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base-url:
|
||||
description: >-
|
||||
The tree to install from, for example
|
||||
https://developer.pgadmin.org/builds/2026-09-21-1
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: 'acceptance-test @ ${{ inputs.base-url }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# The URL is fetched from, installed from and, in the apt job, written into
|
||||
# a file, so it is checked once here rather than trusted in nine places. The
|
||||
# host allowlist is the point: everything below downloads and then installs
|
||||
# as root whatever that host serves, so this is what decides who gets to ask
|
||||
# for that.
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Check the base URL
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${BASE_URL}" in
|
||||
https://developer.pgadmin.org/builds/*) ;;
|
||||
https://ftp.postgresql.org/pub/pgadmin/pgadmin4/*) ;;
|
||||
*)
|
||||
echo "Not a tree this test will install from: ${BASE_URL}" >&2
|
||||
echo "Expected developer.pgadmin.org or ftp.postgresql.org." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
# No trailing slash, no shell metacharacters, nothing that could be
|
||||
# read as a second word: the value is interpolated into commands.
|
||||
printf '%s' "${BASE_URL}" | grep -Eq '^https://[A-Za-z0-9./_-]+[A-Za-z0-9]$' || {
|
||||
echo "The base URL may contain only letters, digits, dot, slash, underscore and hyphen." >&2
|
||||
exit 1; }
|
||||
|
||||
apt:
|
||||
needs: check
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
# The images default to sh, which has no pipefail, so every step that set
|
||||
# it died on the first line. All of these carry bash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: 'bookworm', image: 'debian:12' }
|
||||
- { name: 'trixie', image: 'debian:13' }
|
||||
- { name: 'jammy', image: 'ubuntu:22.04' }
|
||||
- { name: 'noble', image: 'ubuntu:24.04' }
|
||||
- { name: 'resolute', image: 'ubuntu:26.04' }
|
||||
container: ${{ matrix.image }}
|
||||
|
||||
steps:
|
||||
# Only what a user would already have or would obviously need. lsb-release
|
||||
# is here because the README's own sources.list line calls lsb_release,
|
||||
# and following the instruction is the test.
|
||||
- name: Install the prerequisites
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl gnupg lsb-release
|
||||
|
||||
- name: Follow the README
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub \
|
||||
| gpg --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg
|
||||
|
||||
# Written by this shell rather than handed to `sh -c`, which
|
||||
# re-parsed the expanded URL as source and was the one place in the
|
||||
# pipeline where a caller-supplied value reached a shell.
|
||||
CODENAME=$(lsb_release -cs)
|
||||
echo "deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] ${BASE_URL}/apt/${CODENAME} pgadmin4 main" \
|
||||
> /etc/apt/sources.list.d/pgadmin4.list
|
||||
cat /etc/apt/sources.list.d/pgadmin4.list
|
||||
|
||||
# A repository apt will not verify makes this fail here, before any
|
||||
# package is considered.
|
||||
apt-get update
|
||||
|
||||
- name: Install pgAdmin
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get install -y pgadmin4-server
|
||||
|
||||
# The version proves what installed is this build rather than
|
||||
# something the distribution happened to carry, and the file list
|
||||
# proves the package actually delivered its payload.
|
||||
dpkg-query -W -f '${Package} ${Version}\n' pgadmin4-server
|
||||
# grep without -q, deliberately: -q exits on the first match, the
|
||||
# command feeding it then dies of SIGPIPE, and pipefail turns that
|
||||
# into a failed pipeline even though the match succeeded.
|
||||
dpkg -L pgadmin4-server | grep '^/usr/pgadmin4/' >/dev/null || {
|
||||
echo "pgadmin4-server installed no files under /usr/pgadmin4" >&2
|
||||
exit 1; }
|
||||
|
||||
yum:
|
||||
needs: check
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
# The images default to sh, which has no pipefail, so every step that set
|
||||
# it died on the first line. All of these carry bash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: 'rhel-9', image: 'almalinux:9', family: 'redhat' }
|
||||
- { name: 'rhel-10', image: 'almalinux:10', family: 'redhat' }
|
||||
- { name: 'fedora-43', image: 'fedora:43', family: 'fedora' }
|
||||
- { name: 'fedora-44', image: 'fedora:44', family: 'fedora' }
|
||||
container: ${{ matrix.image }}
|
||||
|
||||
steps:
|
||||
- name: Follow the README
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
FAMILY: ${{ matrix.family }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The repository RPM carries the .repo file and the packaging key, and
|
||||
# points at the tree it shipped in, so nothing here names a URL twice.
|
||||
# It sets gpgcheck=1 and repo_gpgcheck=1, which is why no --nogpgcheck
|
||||
# appears below: a wrongly signed repository fails at this step.
|
||||
RPM=$(curl -fsS "${BASE_URL}/yum/" \
|
||||
| grep -oE "pgadmin4-${FAMILY}-repo-[0-9.-]+\.noarch\.rpm" \
|
||||
| sort -u | tail -1)
|
||||
echo "Repository package: ${RPM}"
|
||||
rpm -i "${BASE_URL}/yum/${RPM}"
|
||||
cat /etc/yum.repos.d/pgadmin4.repo
|
||||
|
||||
- name: Install pgAdmin
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dnf install -y pgadmin4-server
|
||||
rpm -q pgadmin4-server
|
||||
# grep without -q, deliberately: -q exits on the first match, the
|
||||
# command feeding it then dies of SIGPIPE, and pipefail turns that
|
||||
# into a failed pipeline even though the match succeeded.
|
||||
rpm -ql pgadmin4-server | grep '^/usr/pgadmin4/' >/dev/null || {
|
||||
echo "pgadmin4-server installed no files under /usr/pgadmin4" >&2
|
||||
exit 1; }
|
||||
|
||||
# The Windows installer, actually installed. Nothing else in the pipeline
|
||||
# runs it, and until now nothing verified the signature on the executable
|
||||
# inside it either: Make.bat's own check tested a stale %ERRORLEVEL%, and the
|
||||
# workflow step that claimed to cover the gap only re-verified the installer.
|
||||
windows:
|
||||
needs: check
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- name: Download and install
|
||||
shell: pwsh
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$listing = Invoke-WebRequest -Uri "$env:BASE_URL/" -UseBasicParsing
|
||||
$name = ([regex]'pgadmin4-[0-9.]+-x64\.exe').Matches($listing.Content) |
|
||||
ForEach-Object { $_.Value } | Sort-Object -Unique | Select-Object -Last 1
|
||||
if (-not $name) { throw "No installer found at $env:BASE_URL" }
|
||||
Write-Output "Installer: $name"
|
||||
|
||||
Invoke-WebRequest -Uri "$env:BASE_URL/$name" -OutFile installer.exe -UseBasicParsing
|
||||
|
||||
# The installer's own signature, before running anything from it.
|
||||
$sig = Get-AuthenticodeSignature installer.exe
|
||||
Write-Output "Installer signature: $($sig.Status) / $($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne 'Valid') { throw "Installer signature is $($sig.Status)" }
|
||||
|
||||
# Inno Setup's silent switches. /DIR is given so the check below does
|
||||
# not depend on the default install location.
|
||||
$p = Start-Process -FilePath .\installer.exe -Wait -PassThru `
|
||||
-ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/DIR=C:\pgadmin4-test'
|
||||
if ($p.ExitCode -ne 0) { throw "Installer exited with $($p.ExitCode)" }
|
||||
|
||||
- name: Check what was installed
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = 'C:\pgadmin4-test'
|
||||
|
||||
# The components that carry no signature of ours, and so are only
|
||||
# checked for presence. Everything we do sign is covered by the step
|
||||
# below, which checks that it is there as a side effect of verifying
|
||||
# it.
|
||||
foreach ($f in @("$root\python\python.exe", "$root\web\pgAdmin4.py")) {
|
||||
if (-not (Test-Path $f)) { throw "Missing from the installation: $f" }
|
||||
Write-Output "present: $f"
|
||||
}
|
||||
|
||||
# Everything Make.bat's :SIGN_COMPONENTS signs, verified where it lands.
|
||||
# None of it exists outside the installer until the installer has run, so
|
||||
# this is the only place a published build can be checked: the runtime
|
||||
# executable, and the PostgreSQL and Kerberos binaries we ship alongside
|
||||
# it. The Electron, Python and VC++ runtime components are deliberately
|
||||
# not signed by us and so are not listed here.
|
||||
- name: Check the component signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = 'C:\pgadmin4-test'
|
||||
|
||||
# The publisher is taken from the installer rather than hard coded,
|
||||
# so that a component signed by somebody else is caught without this
|
||||
# needing an edit every time the certificate is renewed.
|
||||
$expected = (Get-AuthenticodeSignature installer.exe).SignerCertificate.Subject
|
||||
Write-Output "Expected publisher: $expected"
|
||||
|
||||
# winpgbuild does not always ship these four, and Make.bat copies them
|
||||
# only when they are present, so an empty match is not a failure.
|
||||
$optional = @('libintl-*.dll', 'libiconv-*.dll', 'liblz4.dll', 'libzstd.dll')
|
||||
|
||||
foreach ($pattern in @(
|
||||
'pgAdmin4.exe',
|
||||
'libpq.dll', 'libcrypto-*-x64.dll', 'libssl-*-x64.dll', 'zlib1.dll',
|
||||
'pg_dump.exe', 'pg_dumpall.exe', 'pg_restore.exe', 'psql.exe',
|
||||
'kinit.exe', 'krb5_64.dll', 'comerr64.dll', 'k5sprt64.dll',
|
||||
'gssapi64.dll', 'krbcc64.dll', 'ccapiserver.exe') + $optional) {
|
||||
|
||||
$files = @(Get-ChildItem -Path "$root\runtime\$pattern" -ErrorAction SilentlyContinue)
|
||||
if ($files.Count -eq 0) {
|
||||
if ($optional -contains $pattern) {
|
||||
Write-Output "absent, and optional: $pattern"
|
||||
continue
|
||||
}
|
||||
throw "Missing from the installation: $root\runtime\$pattern"
|
||||
}
|
||||
|
||||
foreach ($f in $files) {
|
||||
$sig = Get-AuthenticodeSignature $f.FullName
|
||||
Write-Output "$($f.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
throw "$($f.Name) signature is $($sig.Status)"
|
||||
}
|
||||
if ($sig.SignerCertificate.Subject -ne $expected) {
|
||||
throw "$($f.Name) was signed by $($sig.SignerCertificate.Subject)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The macOS disk image as published, rather than the bundle as built. The
|
||||
# snapshot workflow checks the bundle it has just made; this checks what a
|
||||
# user downloads, which has been through a DMG, a web server and a browser's
|
||||
# quarantine attribute.
|
||||
macos:
|
||||
needs: check
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: 'arm64', runner: 'macos-15' }
|
||||
- { name: 'x64', runner: 'macos-26-intel' }
|
||||
steps:
|
||||
- name: Download, mount and check
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
ARCH: ${{ matrix.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name=$(curl -fsS "${BASE_URL}/" \
|
||||
| grep -oE "pgadmin4-[0-9.]+-${ARCH}\.dmg" | sort -u | tail -1)
|
||||
[ -n "${name}" ] || { echo "No ${ARCH} disk image found" >&2; exit 1; }
|
||||
echo "Disk image: ${name}"
|
||||
curl -fsS -o image.dmg "${BASE_URL}/${name}"
|
||||
|
||||
# Quarantine it as a browser would, so Gatekeeper is actually
|
||||
# consulted rather than bypassed because the file arrived by curl.
|
||||
xattr -w com.apple.quarantine \
|
||||
"0081;$(printf '%x' "$(date +%s)");Safari;" image.dmg
|
||||
|
||||
hdiutil attach image.dmg -nobrowse -readonly -mountpoint /tmp/pga
|
||||
app="/tmp/pga/pgAdmin 4.app"
|
||||
|
||||
test -d "${app}"
|
||||
test -x "${app}/Contents/MacOS/pgAdmin 4"
|
||||
test -x "${app}/Contents/SharedSupport/pg_dump"
|
||||
test -f "${app}/Contents/Resources/web/pgAdmin4.py"
|
||||
|
||||
# Gatekeeper first, because that is what decides whether a user can
|
||||
# open this. codesign --strict comes after and is allowed to have its
|
||||
# own opinion: the disk image deliberately carries a FinderInfo xattr
|
||||
# on the bundle, set by SetFile -a E in _create_dmg to hide the .app
|
||||
# extension in the window, and --strict rejects any xattr at all.
|
||||
echo "=== notarisation and Gatekeeper ==="
|
||||
# stapler looks the ticket up in Apple's CloudKit, and a dropped
|
||||
# connection there fails the step with error 68, so it is retried.
|
||||
# A genuinely broken ticket still fails all three attempts.
|
||||
for attempt in 1 2 3; do
|
||||
xcrun stapler validate "${app}" && break
|
||||
[ "${attempt}" -lt 3 ] || exit 1
|
||||
echo "stapler validate failed; retrying in $((attempt * 30))s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
spctl --assess --type execute --verbose=4 "${app}"
|
||||
|
||||
echo "=== signature ==="
|
||||
codesign -dvvv "${app}" 2>&1 | grep -E 'Authority|TeamIdentifier'
|
||||
codesign --verify --deep --verbose=2 "${app}"
|
||||
|
||||
# Reported rather than enforced. If this ever starts failing whilst
|
||||
# spctl still passes, it is the extension-hiding attribute and not a
|
||||
# broken signature; if spctl fails too, the build is broken.
|
||||
if ! codesign --verify --deep --strict --verbose=2 "${app}" 2>/tmp/strict.log; then
|
||||
echo "note: --strict verification failed, Gatekeeper having passed:"
|
||||
tail -2 /tmp/strict.log
|
||||
fi
|
||||
|
||||
# Nothing in the bundle may reference a build machine. This is what
|
||||
# catches a dependency that was linked but not relocated, which is
|
||||
# how an ICU or OpenSSL path would escape.
|
||||
echo "=== linkage of the bundled client binaries ==="
|
||||
for b in pg_dump pg_dumpall pg_restore psql; do
|
||||
echo "--- ${b} ---"
|
||||
otool -L "${app}/Contents/SharedSupport/${b}" | tail -n +2
|
||||
done
|
||||
if otool -L "${app}/Contents/SharedSupport/"* \
|
||||
| grep -E '/opt/pgbuild|/opt/homebrew|/usr/local/(lib|opt)'; then
|
||||
echo "A bundled binary still points outside the bundle" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hdiutil detach /tmp/pga
|
||||
|
||||
# The downloads nobody installs: the wheel, the tarballs and the documents.
|
||||
# Shallow by design, but it does verify every detached signature from the
|
||||
# published URL rather than on the server, which is what a user would get.
|
||||
artefacts:
|
||||
needs: check
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Fetch and check
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base-url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dl && cd dl
|
||||
|
||||
listing=$(curl -fsS "${BASE_URL}/")
|
||||
fetch() {
|
||||
local pattern=$1 name
|
||||
name=$(printf '%s' "${listing}" | grep -oE "${pattern}" | sort -u | tail -1)
|
||||
[ -n "${name}" ] || { echo "Nothing matching ${pattern}" >&2; exit 1; }
|
||||
curl -fsS -O "${BASE_URL}/${name}"
|
||||
echo "${name}"
|
||||
}
|
||||
|
||||
tarball=$(fetch 'pgadmin4-[0-9.]+\.tar\.gz')
|
||||
wheel=$(fetch 'pgadmin4-[0-9.]+-py3-none-any\.whl')
|
||||
docs=$(fetch 'pgadmin4-[0-9.]+-docs\.tar\.gz')
|
||||
pdf=$(fetch 'pgadmin4-[0-9.]+\.pdf')
|
||||
epub=$(fetch 'pgadmin4-[0-9.]+\.epub')
|
||||
|
||||
echo "=== signatures, as a user would verify them ==="
|
||||
export GNUPGHOME=$(mktemp -d); chmod 700 "${GNUPGHOME}"
|
||||
curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | gpg --import
|
||||
for f in "${tarball}" "${wheel}" "${docs}" "${pdf}" "${epub}"; do
|
||||
curl -fsS -O "${BASE_URL}/${f}.asc"
|
||||
gpg --verify "${f}.asc" "${f}"
|
||||
done
|
||||
|
||||
echo "=== the documents are what they claim to be ==="
|
||||
file "${pdf}" | grep -q 'PDF document'
|
||||
file "${epub}" | grep -q 'EPUB'
|
||||
# A truncated PDF still says PDF, so size is checked too.
|
||||
[ "$(stat -c%s "${pdf}")" -gt 1000000 ] || { echo "PDF is implausibly small" >&2; exit 1; }
|
||||
|
||||
echo "=== the tarballs contain what they should ==="
|
||||
tar tzf "${tarball}" | grep -E '/web/pgAdmin4\.py$' >/dev/null
|
||||
tar tzf "${tarball}" | grep -E '/requirements\.txt$' >/dev/null
|
||||
tar tzf "${docs}" | grep -E 'index\.html$' >/dev/null
|
||||
|
||||
- name: Install the wheel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dl
|
||||
python3 -m venv /tmp/wheeltest
|
||||
/tmp/wheeltest/bin/pip install --quiet pgadmin4-*-py3-none-any.whl
|
||||
/tmp/wheeltest/bin/python -c 'import pgadmin4; print("imported", pgadmin4.__file__)'
|
||||
@@ -5,6 +5,25 @@ on:
|
||||
# rather than a second definition of it that can drift. Snapshot does not
|
||||
# call it today, the image not being part of the download tree.
|
||||
workflow_call:
|
||||
inputs:
|
||||
publish:
|
||||
description: >-
|
||||
Build for both architectures and push to the registry, tagged
|
||||
with the location below. A build check does neither: it proves
|
||||
the Dockerfile still works and throws the image away.
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
location:
|
||||
description: The tag to push, normally the build's date
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: false
|
||||
DOCKERHUB_TOKEN:
|
||||
required: false
|
||||
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
@@ -12,6 +31,10 @@ on:
|
||||
- 'Dockerfile'
|
||||
- 'pkg/docker/**'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'docs/**'
|
||||
- '.dockerignore'
|
||||
@@ -23,6 +46,10 @@ on:
|
||||
- 'Dockerfile'
|
||||
- 'pkg/docker/**'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'docs/**'
|
||||
- '.dockerignore'
|
||||
@@ -30,6 +57,17 @@ on:
|
||||
- '.github/workflows/check-container-build.yml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Build for both architectures and push to the registries
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
location:
|
||||
description: The tag to push, normally the build's date
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
# The workflow's own name, spelled out rather than taken from
|
||||
@@ -44,8 +82,18 @@ concurrency:
|
||||
group: 'check-container-build @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }} @ ${{ github.event_name }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
# Read-only by default, as everywhere else here. The publish job below names
|
||||
# the wider set it genuinely needs, and a job-level block replaces this one
|
||||
# rather than adding to it, so its packages: write is unaffected.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# The check. No environment, and so no access to the registry credentials:
|
||||
# this runs on every pull request, and a build check has no business being
|
||||
# able to push anything.
|
||||
build-container:
|
||||
if: ${{ !inputs.publish }}
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
@@ -53,8 +101,75 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Build the container
|
||||
uses: docker/build-push-action@v7
|
||||
- name: Check the container builds
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
push: false
|
||||
context: .
|
||||
|
||||
# The publish. Declared on the publishing environment, which is where the
|
||||
# Docker Hub credentials live: an environment secret is not available to a
|
||||
# job that does not name the environment, which is what secrets: inherit
|
||||
# alone does not solve.
|
||||
publish-container:
|
||||
if: inputs.publish
|
||||
runs-on: ubuntu-22.04
|
||||
environment: publishing
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For GHCR, which takes the workflow's own token rather than a stored
|
||||
# registry credential.
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# Before anything is logged in to, because this value becomes a tag in
|
||||
# a job holding the Docker Hub credentials, and the tags input is
|
||||
# newline separated: an unchecked value could name a second tag, and a
|
||||
# tag can name another repository. The shape is the one the wrapper
|
||||
# requires of a build location.
|
||||
- name: Check the location
|
||||
env:
|
||||
LOCATION: ${{ inputs.location }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "${LOCATION}" \
|
||||
| grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}(-([1-9][0-9]?|[A-Za-z][A-Za-z0-9]*))?$' || {
|
||||
echo "Not a build location: ${LOCATION}" >&2; exit 1; }
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
# Emulation is slow, so only the publishing path pays for it.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tagged with the build's location rather than a version. Promotion
|
||||
# re-tags this exact image, so what ships is the manifest that was
|
||||
# tested rather than a rebuild of the same source.
|
||||
- name: Build and push the container
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
dpage/pgadmin4:${{ inputs.location }}
|
||||
ghcr.io/pgadmin-org/pgadmin4:${{ inputs.location }}
|
||||
@@ -16,11 +16,15 @@ on:
|
||||
workflow_call:
|
||||
|
||||
push:
|
||||
branches: [ "master", "buildfarm-to-actions" ]
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'pkg/debian/**'
|
||||
- 'pkg/linux/**'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
push:
|
||||
branches: [ "master", "buildfarm-to-actions" ]
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'pkg/helm/**'
|
||||
- '.github/workflows/check-helm-build.yml'
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Install Node modules
|
||||
run: |
|
||||
cd web
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
|
||||
- name: Run the linter
|
||||
run: |
|
||||
|
||||
@@ -18,11 +18,18 @@ name: Check macOS builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "buildfarm-to-actions" ]
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'pkg/mac/**'
|
||||
- '.github/actions/install-pgbuild-deps/action.yml'
|
||||
# Which build of each dependency this compiles against, so a
|
||||
# change to it changes what the installer contains.
|
||||
- 'pkg/pgbuild-deps.lock'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
|
||||
@@ -10,6 +10,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'pkg/pip/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
@@ -22,6 +26,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'pkg/pip/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
|
||||
@@ -19,11 +19,15 @@ on:
|
||||
workflow_call:
|
||||
|
||||
push:
|
||||
branches: [ "master", "buildfarm-to-actions" ]
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'pkg/redhat/**'
|
||||
- 'pkg/linux/**'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
|
||||
@@ -10,6 +10,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'pkg/src/**'
|
||||
- 'requirements.txt'
|
||||
@@ -22,6 +26,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'pkg/src/**'
|
||||
- 'requirements.txt'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Replaces the pgadmin4-windows-x64-qa Jenkins job.
|
||||
#
|
||||
# This runs on a hosted runner, not a self-hosted one. Code signing in Make.bat
|
||||
# is conditional on PGADMIN_WINDOWS_CSC being set (see Make.bat:393, :447 and
|
||||
# :469), so an unsigned build check needs neither the Certum hardware token nor
|
||||
# the machine it is plugged into. Only the release and snapshot builds, which
|
||||
# do sign, need the self-hosted runner.
|
||||
# is conditional on PGADMIN_WINDOWS_CSC being set (see :SIGN_FILES,
|
||||
# :CREATE_INSTALLER and :VERIFY_SIGNATURE in Make.bat), so an unsigned build
|
||||
# check needs neither the Certum hardware token nor the machine it is plugged
|
||||
# into. Only the release and snapshot builds, which do sign, need the
|
||||
# self-hosted runner.
|
||||
#
|
||||
# Dependencies come from dpage/winpgbuild, which is exactly where the
|
||||
# buildfarm's winpgbuild-update job gets them, so the toolchain matches what
|
||||
@@ -17,13 +18,20 @@ name: Check Windows build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "buildfarm-to-actions" ]
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'Make.bat'
|
||||
- 'pkg/win32/**'
|
||||
- 'pkg/python-version.txt'
|
||||
- '.github/actions/install-pgbuild-deps/action.yml'
|
||||
# Which build of each dependency this compiles against, so a
|
||||
# change to it changes what the installer contains.
|
||||
- 'pkg/pgbuild-deps.lock'
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'docs/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/check-windows-build.yml'
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# Promote a tested release candidate to the download server.
|
||||
#
|
||||
# This is the irreversible one. Everything before it produces something that
|
||||
# can be rebuilt or deleted without anybody noticing; this puts packages in
|
||||
# front of every pgAdmin user, and within the hour they are on the PostgreSQL
|
||||
# mirror network and in the S3 archive, which has no --delete and therefore no
|
||||
# undo.
|
||||
#
|
||||
# So it builds nothing. It takes a candidate that already exists on the
|
||||
# development server, having been built, signed, indexed and installed from by
|
||||
# the acceptance test, and moves it across. If a candidate needs changing, the
|
||||
# answer is a new candidate rather than a repair applied here.
|
||||
#
|
||||
# The version is separate from the location deliberately: 2026-09-21-1 is a
|
||||
# build, 9.18 is what users will call it, and conflating them is how somebody
|
||||
# publishes the wrong tree under the right name.
|
||||
|
||||
name: Promote release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish, for example 9.18'
|
||||
required: true
|
||||
type: string
|
||||
location:
|
||||
description: 'Candidate to promote, for example 2026-09-21-1'
|
||||
required: true
|
||||
type: string
|
||||
confirm:
|
||||
description: 'Type the version again to confirm'
|
||||
required: true
|
||||
type: string
|
||||
container-tags:
|
||||
description: >-
|
||||
Space separated tags for the container, for example
|
||||
"9 9.18 9.18.0 latest". Required when scope is everything.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
scope:
|
||||
description: >-
|
||||
How much to do. "everything" is a real promotion. "files" stops
|
||||
before the website and the S3 archive, neither of which can be
|
||||
undone. "release-directory" stops before the package
|
||||
repositories as well, touching only v<VERSION>, which is the
|
||||
only setting that leaves the live repositories alone and so the
|
||||
only one safe to rehearse the download server with.
|
||||
"registries" is the other half: it skips the download server
|
||||
entirely and runs only the container re-tag and the chart push,
|
||||
so those can be exercised against a candidate tag without a
|
||||
release. It does not upload to PyPI, so that a rehearsal cannot
|
||||
publish a candidate's wheel. "pypi" runs only the PyPI upload,
|
||||
for finishing a promotion whose upload failed.
|
||||
type: choice
|
||||
required: true
|
||||
default: release-directory
|
||||
options:
|
||||
- release-directory
|
||||
- files
|
||||
- everything
|
||||
- registries
|
||||
- pypi
|
||||
|
||||
concurrency:
|
||||
group: 'promote-release'
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Every input is checked here, before either of the jobs that act on one
|
||||
# runs, because the checks used to live in promote and the registries scope
|
||||
# skips that job: under that scope the confirmation gate did not apply to
|
||||
# the step that moves the latest tag.
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Check the request
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
CONFIRM: ${{ inputs.confirm }}
|
||||
TAGS: ${{ inputs.container-tags }}
|
||||
SCOPE: ${{ inputs.scope }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Typed twice, because the cost of getting this wrong is a version
|
||||
# published from the wrong tree and no way to unpublish it.
|
||||
if [ "${VERSION}" != "${CONFIRM}" ]; then
|
||||
echo "The confirmation does not match the version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The same shapes the wrapper enforces, checked here so a mistake
|
||||
# costs a second rather than a round trip.
|
||||
printf '%s' "${VERSION}" | grep -Eq '^[0-9]{1,2}\.[0-9]{1,2}(\.[0-9]{1,2})?$' || {
|
||||
echo "Not a version: ${VERSION}" >&2; exit 1; }
|
||||
printf '%s' "${LOCATION}" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}(-[1-9][0-9]?)?$' || {
|
||||
echo "Not a build location: ${LOCATION}" >&2; exit 1; }
|
||||
|
||||
# Only the scopes that publish containers need tags, but when they
|
||||
# are given they are checked whatever the scope, so a bad value is
|
||||
# not discovered halfway through a promotion.
|
||||
if [ "${SCOPE}" = "everything" ] || [ "${SCOPE}" = "registries" ]; then
|
||||
[ -n "${TAGS}" ] || {
|
||||
echo "container-tags is required for scope ${SCOPE}." >&2
|
||||
exit 1; }
|
||||
fi
|
||||
# -f, because an unquoted expansion is also a glob: a tag of * would
|
||||
# otherwise be replaced by whatever is in the working directory.
|
||||
set -f
|
||||
for tag in ${TAGS}; do
|
||||
printf '%s' "${tag}" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' || {
|
||||
echo "Not a container tag: ${tag}" >&2; exit 1; }
|
||||
done
|
||||
set +f
|
||||
|
||||
echo "Request accepted: ${VERSION} from ${LOCATION}, scope ${SCOPE}."
|
||||
|
||||
promote:
|
||||
needs: check
|
||||
# Skipped entirely when only the registries are being exercised, so a
|
||||
# rehearsal of those never touches the download server.
|
||||
if: ${{ inputs.scope != 'registries' && inputs.scope != 'pypi' }}
|
||||
runs-on: [self-hosted, publish]
|
||||
environment: publishing
|
||||
|
||||
steps:
|
||||
# The inputs are checked in the job above; this is about the runner.
|
||||
- name: Check the runner
|
||||
env:
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
[ -n "${DOWNLOAD}" ] || { echo "DOWNLOAD_SSH_TARGET is not set." >&2; exit 1; }
|
||||
|
||||
# Both variables reach option positions: the target is the first
|
||||
# positional argument to every ssh below, and the key path follows
|
||||
# -i, so a value beginning with a hyphen would be read as an option
|
||||
# rather than as the host or the path it is meant to be. Only an
|
||||
# administrator can set a repository variable, which makes this
|
||||
# defence in depth rather than a live hole, but it is checked here,
|
||||
# once, before anything is published; the -- at each call site
|
||||
# closes the same gap from the other end.
|
||||
TARGET_RE='^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9.-]*)?$'
|
||||
printf '%s' "${DOWNLOAD}" | grep -Eq "${TARGET_RE}" || {
|
||||
echo "DOWNLOAD_SSH_TARGET is not a [user@]host." >&2; exit 1; }
|
||||
|
||||
KEY="${PUBLISH_KEY/#\~/$HOME}"
|
||||
printf '%s' "${KEY}" | grep -Eq '^/[A-Za-z0-9._/-]+$' || {
|
||||
echo "PUBLISH_SSH_KEY is not a plain absolute path." >&2
|
||||
exit 1; }
|
||||
[ -r "${KEY}" ] || { echo "Missing SSH key: ${KEY}" >&2; exit 1; }
|
||||
if head -c 4 "${KEY}" | grep -q '^ssh-'; then
|
||||
echo "${KEY} is a public key; -i needs the private one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Checked before anything is created, so that a version published by
|
||||
# mistake is caught here rather than by release-create failing halfway
|
||||
# through and leaving a directory behind.
|
||||
- name: Refuse to republish
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish() {
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" "$@"
|
||||
}
|
||||
if publish release-exists "${VERSION}" 2>/dev/null; then
|
||||
echo "Version ${VERSION} is already published." >&2
|
||||
echo "Publishing is single-shot; a correction needs a new version." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Version ${VERSION} is not yet published."
|
||||
|
||||
- name: Publish the release directory
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish() {
|
||||
echo "+ $*"
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" "$@"
|
||||
}
|
||||
|
||||
# The downloads first: the directory, then the files sorted into it,
|
||||
# then the packages into the live repository trees. Nothing is
|
||||
# visible to a package manager until the indices are rebuilt below,
|
||||
# so an interruption here leaves packages that nothing points at
|
||||
# rather than a repository describing packages that are not there.
|
||||
publish release-create "${VERSION}"
|
||||
publish release-fetch "${VERSION}" "${LOCATION}"
|
||||
|
||||
# Separate from the step above because this is where a promotion stops
|
||||
# being reversible in practice. The step above creates one directory that
|
||||
# can simply be deleted; this copies packages into the live repositories
|
||||
# and re-signs the indices every client reads.
|
||||
- name: Publish the packages
|
||||
if: ${{ inputs.scope != 'release-directory' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish() {
|
||||
echo "+ $*"
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" "$@"
|
||||
}
|
||||
|
||||
# The auto-updater archives, which release-fetch deliberately left
|
||||
# behind because they belong outside the release directory.
|
||||
publish autoupdate-publish "${VERSION}"
|
||||
|
||||
publish packages-fetch "${LOCATION}"
|
||||
|
||||
# The indices, which is the point at which the new packages become
|
||||
# installable.
|
||||
for codename in bookworm trixie jammy noble resolute; do
|
||||
publish rebuild-apt "${codename}"
|
||||
done
|
||||
publish rebuild-yum redhat rhel 9 x86_64
|
||||
publish rebuild-yum redhat rhel 10 x86_64
|
||||
publish rebuild-yum fedora fedora 43 x86_64
|
||||
publish rebuild-yum fedora fedora 44 x86_64
|
||||
|
||||
# The platform tables carry the release history, so they change with
|
||||
# every release rather than only when a platform is added.
|
||||
publish rebuild-readme apt
|
||||
publish rebuild-readme yum
|
||||
|
||||
- name: Update the website
|
||||
if: ${{ inputs.scope == 'everything' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish() {
|
||||
echo "+ $*"
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" "$@"
|
||||
}
|
||||
|
||||
# Separate from the packages, and after them, because the website
|
||||
# announcing a release whose files are not there yet is worse than
|
||||
# files sitting there unannounced for a minute.
|
||||
publish create-release "${VERSION}"
|
||||
publish load-docs "${VERSION}"
|
||||
publish purge-cache
|
||||
|
||||
- name: Archive to S3
|
||||
if: ${{ inputs.scope == 'everything' }}
|
||||
env:
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Last, and on its own, because it is the slowest step and the one
|
||||
# with no undo: the sync has no --delete, so anything it copies is
|
||||
# in the archive permanently.
|
||||
echo "+ sync-s3"
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" sync-s3
|
||||
|
||||
- name: Note what was skipped
|
||||
if: ${{ inputs.scope != 'everything' }}
|
||||
env:
|
||||
SCOPE: ${{ inputs.scope }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
echo "scope was ${SCOPE}, so this was not a full promotion."
|
||||
if [ "${SCOPE}" = "release-directory" ]; then
|
||||
echo "Only /var/ftp/pgadmin4/v${VERSION} was written. The package"
|
||||
echo "repositories, the website and the S3 archive are untouched,"
|
||||
echo "so removing that directory undoes this entirely."
|
||||
else
|
||||
echo "The website was not updated and nothing was archived to S3."
|
||||
echo "The packages are live and will reach the mirrors at the next"
|
||||
echo "hourly sync."
|
||||
fi
|
||||
|
||||
# The registries, which need none of the publishing keys and no self-hosted
|
||||
# runner:
|
||||
# nothing here touches our servers. Separate so that a Docker Hub outage
|
||||
# cannot leave a half-finished promotion on the download server, and so the
|
||||
# runner holding the SSH keys never handles registry credentials.
|
||||
registries:
|
||||
needs: [check, promote]
|
||||
# always(), because under the registries scope promote is skipped rather
|
||||
# than run, and a plain needs would skip this with it. check is never
|
||||
# skipped, so requiring its success is what keeps the inputs validated on
|
||||
# both paths.
|
||||
if: >-
|
||||
${{ always()
|
||||
&& needs.check.result == 'success'
|
||||
&& (inputs.scope == 'everything' || inputs.scope == 'registries'
|
||||
|| inputs.scope == 'pypi')
|
||||
&& (needs.promote.result == 'success'
|
||||
|| needs.promote.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: publishing
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For PyPI trusted publishing, which exchanges this for a short-lived
|
||||
# upload credential rather than using a stored API token.
|
||||
id-token: write
|
||||
# For GHCR, which needs no stored credential at all.
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# By commit rather than by tag, since this script is piped into a shell
|
||||
# in the same job that then logs in to two registries, and a tag can be
|
||||
# moved. bec5b06 is v4.3.0.
|
||||
- name: Install Helm
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL https://raw.githubusercontent.com/helm/helm/bec5b06ed841fe5269972d864d5177944fd5970f/scripts/get-helm-3 -o get-helm-3
|
||||
chmod +x get-helm-3
|
||||
./get-helm-3
|
||||
|
||||
# Re-tagged rather than rebuilt, so what ships is the manifest the
|
||||
# acceptance test ran against. imagetools works registry-side, so
|
||||
# nothing is pulled or pushed through this runner.
|
||||
- name: Publish the container
|
||||
if: ${{ inputs.scope != 'pypi' }}
|
||||
env:
|
||||
LOCATION: ${{ inputs.location }}
|
||||
TAGS: ${{ inputs.container-tags }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Validated by the check job. -f so that the word splitting below
|
||||
# does not also glob against the workspace.
|
||||
set -f
|
||||
|
||||
printf '%s' "${DOCKERHUB_TOKEN}" \
|
||||
| docker login -u "${DOCKERHUB_USERNAME}" --password-stdin
|
||||
printf '%s' "${GITHUB_TOKEN}" \
|
||||
| docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
|
||||
|
||||
# Docker Hub stays canonical, because every tutorial, compose file
|
||||
# and manifest in the world names dpage/pgadmin4. GHCR is published
|
||||
# alongside it: organisation owned, no stored credential, and not
|
||||
# subject to Docker Hub's anonymous pull limits.
|
||||
for tag in ${TAGS}; do
|
||||
echo "+ ${LOCATION} -> :${tag}"
|
||||
docker buildx imagetools create \
|
||||
--tag "dpage/pgadmin4:${tag}" \
|
||||
--tag "ghcr.io/pgadmin-org/pgadmin4:${tag}" \
|
||||
"dpage/pgadmin4:${LOCATION}"
|
||||
done
|
||||
docker logout ghcr.io
|
||||
docker logout
|
||||
|
||||
# The chart carries the release version from pkg/helm/Chart.yaml, so
|
||||
# unlike the container there is no candidate copy to re-tag: it is
|
||||
# packaged and pushed once, here.
|
||||
#
|
||||
# To GHCR rather than Docker Hub. The chart's install base is a small
|
||||
# fraction of the container's, so moving it breaks far less than moving
|
||||
# the image would, oci://docker.io/dpage was an odd home for a chart in
|
||||
# the first place, and it needs no stored credential here.
|
||||
- name: Publish the Helm chart
|
||||
if: ${{ inputs.scope != 'pypi' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "${GITHUB_TOKEN}" \
|
||||
| helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
|
||||
helm package pkg/helm
|
||||
helm push pgadmin4-helm-*.tgz oci://ghcr.io/pgadmin-org
|
||||
helm registry logout ghcr.io
|
||||
|
||||
# Fetched from the candidate rather than rebuilt, so what reaches PyPI
|
||||
# is the file the acceptance test installed. Not from the mirrors: they
|
||||
# sync from the download server hourly, so the release is not there
|
||||
# yet when this runs.
|
||||
- name: Collect the wheel
|
||||
if: ${{ inputs.scope == 'everything' || inputs.scope == 'pypi' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p pypi-dist
|
||||
BASE="https://developer.pgadmin.org/builds/${LOCATION}"
|
||||
WHEEL="pgadmin4-${VERSION}-py3-none-any.whl"
|
||||
curl -fsS -o "pypi-dist/${WHEEL}" "${BASE}/${WHEEL}"
|
||||
# Beside the wheel rather than in pypi-dist, because everything in
|
||||
# that directory is uploaded and PyPI has not accepted detached
|
||||
# signatures since 2023.
|
||||
curl -fsS -o "${WHEEL}.asc" "${BASE}/${WHEEL}.asc"
|
||||
ls -l pypi-dist
|
||||
|
||||
# The wheel arrives over TLS, which says which server sent it and
|
||||
# nothing about how it got there, and the signature that has been
|
||||
# sitting beside the file all along is the only thing that ties these
|
||||
# bytes back to the signing key. Checking it
|
||||
# costs a second and turns the last hop of a release into something an
|
||||
# attacker cannot quietly alter.
|
||||
- name: Verify the wheel before uploading it
|
||||
if: ${{ inputs.scope == 'everything' || inputs.scope == 'pypi' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsS -o packages_pgadmin_org.pub \
|
||||
https://www.pgadmin.org/static/packages_pgadmin_org.pub
|
||||
|
||||
# A keyring of its own, holding that one key and nothing the runner
|
||||
# image happened to trust already, so a signature made by any other
|
||||
# key fails for want of a public key rather than passing on the
|
||||
# strength of somebody else's.
|
||||
GNUPGHOME=$(mktemp -d)
|
||||
export GNUPGHOME
|
||||
gpg --batch --import packages_pgadmin_org.pub
|
||||
gpg --batch --verify "pgadmin4-${VERSION}-py3-none-any.whl.asc" \
|
||||
"pypi-dist/pgadmin4-${VERSION}-py3-none-any.whl"
|
||||
echo "Signature verified; the upload may proceed."
|
||||
|
||||
# Trusted publishing: PyPI verifies this workflow's OIDC identity, so
|
||||
# there is no API token to hold, rotate or leak.
|
||||
- name: Upload to PyPI
|
||||
if: ${{ inputs.scope == 'everything' || inputs.scope == 'pypi' }}
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: pypi-dist
|
||||
@@ -0,0 +1,54 @@
|
||||
# Purge the cache in front of the website.
|
||||
#
|
||||
# Promotion purges it once, but the website edits that follow a release, the
|
||||
# downloads being activated, the current version record and the news article,
|
||||
# all happen afterwards and are all cached. This is the button for that, and
|
||||
# for the times somebody edits the site outside a release.
|
||||
#
|
||||
# It takes no arguments: the verb behind it clears everything, and there is
|
||||
# nothing for a caller to choose.
|
||||
|
||||
name: Purge website cache
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: 'purge-website-cache'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
purge:
|
||||
runs-on: [self-hosted, publish]
|
||||
environment: publishing
|
||||
|
||||
steps:
|
||||
- name: Purge
|
||||
env:
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -n "${DOWNLOAD}" ] || { echo "DOWNLOAD_SSH_TARGET is not set." >&2; exit 1; }
|
||||
|
||||
# The target is the first positional argument to ssh and the key
|
||||
# path follows -i, so a value beginning with a hyphen would be read
|
||||
# as an option rather than as what it is meant to be. Only an
|
||||
# administrator can set a repository variable, so this is defence in
|
||||
# depth rather than a live hole, but it costs a line and rules out a
|
||||
# whole shape of mistake; the -- below does the same job from the
|
||||
# other direction.
|
||||
TARGET_RE='^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9.-]*)?$'
|
||||
printf '%s' "${DOWNLOAD}" | grep -Eq "${TARGET_RE}" || {
|
||||
echo "DOWNLOAD_SSH_TARGET is not a [user@]host." >&2; exit 1; }
|
||||
|
||||
KEY="${PUBLISH_KEY/#\~/$HOME}"
|
||||
printf '%s' "${KEY}" | grep -Eq '^/[A-Za-z0-9._/-]+$' || {
|
||||
echo "PUBLISH_SSH_KEY is not a plain absolute path." >&2
|
||||
exit 1; }
|
||||
|
||||
ssh -i "${KEY}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" purge-cache
|
||||
@@ -0,0 +1,439 @@
|
||||
# Release candidate builds, replacing the buildfarm's pgadmin4-*-build jobs.
|
||||
#
|
||||
# The same builds as the nightly snapshot, published to the development server
|
||||
# instead of the download server. That difference is the point: a snapshot is
|
||||
# finished when it is published, whereas a candidate is published in order to
|
||||
# be tested, and promoted to the download server only afterwards.
|
||||
#
|
||||
# developer.pgadmin.org serves this tree over HTTP as soon as it is written, so
|
||||
# a candidate's repositories can be added and installed from immediately,
|
||||
# rather than waiting on the hourly sync between the download server and the
|
||||
# mirrors.
|
||||
#
|
||||
# Never scheduled. A candidate exists because somebody decided to make one.
|
||||
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
location:
|
||||
description: >-
|
||||
Build directory name, normally today's date. A second
|
||||
candidate on the same day needs a -N suffix, for example
|
||||
2026-09-17-2.
|
||||
required: false
|
||||
default: ''
|
||||
publish:
|
||||
description: Upload to the development server when the build succeeds
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
|
||||
concurrency:
|
||||
# One candidate at a time, and never cancelled: a cancelled run can abandon a
|
||||
# notarisation already submitted to Apple, and leaves the signing token in an
|
||||
# unknown state.
|
||||
group: 'release-candidate'
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# The directory name, decided once. Two jobs need it and they must agree:
|
||||
# the repo RPMs carry the candidate's own URL inside them, so if that job and
|
||||
# the assemble job each called date(1) a run straddling midnight would ship a
|
||||
# repository configuration pointing at a build that does not exist.
|
||||
location:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
name: ${{ steps.pick.outputs.name }}
|
||||
steps:
|
||||
- id: pick
|
||||
env:
|
||||
REQUESTED: ${{ inputs.location }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NAME="${REQUESTED:-}"
|
||||
[ -n "${NAME}" ] || NAME=$(date +%Y-%m-%d)
|
||||
|
||||
# Validated here so that everything downstream, including a shell on
|
||||
# the machine holding the publishing keys, is working with a name
|
||||
# that cannot be anything but a date and an optional suffix. The
|
||||
# wrapper on the server applies its own rule to the same value; this
|
||||
# one exists so a bad input fails in five seconds rather than after
|
||||
# an hour of building.
|
||||
if ! printf '%s' "${NAME}" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}(-[1-9][0-9]?)?$'; then
|
||||
echo "Not a valid build location: ${NAME}" >&2
|
||||
echo "Expected YYYY-MM-DD, optionally with a -N suffix." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Build location: ${NAME}"
|
||||
echo "name=${NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
deb:
|
||||
uses: ./.github/workflows/check-deb-build.yml
|
||||
rpm:
|
||||
uses: ./.github/workflows/check-rpm-build.yml
|
||||
tarball:
|
||||
uses: ./.github/workflows/check-tarball-build.yml
|
||||
wheel:
|
||||
uses: ./.github/workflows/check-python-build.yml
|
||||
docs:
|
||||
uses: ./.github/workflows/check-doc-builds.yml
|
||||
# The container, pushed to the registry under this candidate's location so
|
||||
# that it can be tested and then re-tagged at promotion. Not part of the
|
||||
# download tree, which is why it is not in assemble's needs.
|
||||
container:
|
||||
needs: location
|
||||
if: inputs.publish
|
||||
# The caller caps a called workflow's token: the top-level
|
||||
# 'contents: read' leaves 'packages: none', so the publish-container job's
|
||||
# 'packages: write' request must be granted here or the workflow is
|
||||
# rejected before it starts.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/check-container-build.yml
|
||||
with:
|
||||
publish: true
|
||||
location: ${{ needs.location.outputs.name }}
|
||||
secrets: inherit
|
||||
|
||||
# The Helm chart is built as a check but not published here. Its version
|
||||
# comes from pkg/helm/Chart.yaml, so pushing it would publish the release
|
||||
# version before the release, and the registry has no notion of a candidate
|
||||
# the way the download server does. It goes out at promotion.
|
||||
helm:
|
||||
uses: ./.github/workflows/check-helm-build.yml
|
||||
|
||||
# The repository configuration RPMs. These are not built by the QA
|
||||
# workflows, because they are not a build of pgAdmin: they are a one-file
|
||||
# package carrying a .repo pointing at the tree they ship in, which is why
|
||||
# the URL has to be known before they are built.
|
||||
repo-rpm:
|
||||
needs: location
|
||||
runs-on: ubuntu-24.04
|
||||
container: almalinux:9
|
||||
steps:
|
||||
- name: Install the build environment
|
||||
run: dnf install -y --setopt=install_weak_deps=False rpm-build python3 git
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Mark the workspace as a safe git directory
|
||||
run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"
|
||||
|
||||
- name: Build the repository RPMs
|
||||
env:
|
||||
LOCATION: ${{ needs.location.outputs.name }}
|
||||
run: |
|
||||
# Through the environment rather than interpolated into the script.
|
||||
# The location is a dispatch input, so it is text somebody chose, and
|
||||
# workflow expression substitution happens before the shell ever sees the
|
||||
# line, so a crafted location would otherwise become shell text.
|
||||
export PGADMIN_REPO_DIR="https://developer.pgadmin.org/builds/${LOCATION}/yum"
|
||||
echo "Repositories will point at ${PGADMIN_REPO_DIR}"
|
||||
pkg/redhat/repo-rpm.sh
|
||||
|
||||
- name: Archive the repository RPMs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: pgadmin4-repo-rpm-build-output
|
||||
if-no-files-found: error
|
||||
path: dist/*.noarch.rpm
|
||||
|
||||
# snapshot-macos.yml and snapshot-windows.yml are the signed-build
|
||||
# workflows rather than snapshot-only ones; the name predates there being
|
||||
# a second caller.
|
||||
macos:
|
||||
uses: ./.github/workflows/snapshot-macos.yml
|
||||
secrets: inherit
|
||||
|
||||
windows:
|
||||
uses: ./.github/workflows/snapshot-windows.yml
|
||||
secrets: inherit
|
||||
|
||||
assemble:
|
||||
needs: [location, deb, rpm, tarball, wheel, docs, repo-rpm, macos, windows]
|
||||
|
||||
# the runner, not a hosted runner. This job assembles a tree of several
|
||||
# hundred megabytes and then, when publishing, sends it to the development
|
||||
# server; splitting the two meant uploading that tree as an artifact and
|
||||
# immediately downloading it again on the runner, having first queued for a
|
||||
# hosted runner behind whatever else the organisation was building.
|
||||
runs-on: [self-hosted, publish]
|
||||
|
||||
# For the variables naming the keys and the target, which live here. The
|
||||
# environment carries no protection rules, so a run that is not publishing
|
||||
# is not gated by it.
|
||||
environment: publishing
|
||||
|
||||
# Everything must have built except Windows. That is the one runner which
|
||||
# can legitimately be offline, and a candidate without the installer is
|
||||
# still worth having; a candidate missing its documentation, source tarball,
|
||||
# wheel or repository RPMs is not, and the repository RPMs are worse than
|
||||
# missing, since install-repo-readme.py then fails after the packages have
|
||||
# been uploaded and signed, leaving half a candidate on the server.
|
||||
if: >-
|
||||
always()
|
||||
&& needs.location.result == 'success'
|
||||
&& needs.deb.result == 'success'
|
||||
&& needs.rpm.result == 'success'
|
||||
&& needs.tarball.result == 'success'
|
||||
&& needs.wheel.result == 'success'
|
||||
&& needs.docs.result == 'success'
|
||||
&& needs.repo-rpm.result == 'success'
|
||||
&& needs.macos.result == 'success'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Only the build outputs, which are all named pgadmin4-*. The container
|
||||
# build also leaves a 'pgadmin-org~pgadmin4~*.dockerbuild' build-record
|
||||
# artifact that docker/build-push-action uploads; it is not a release
|
||||
# file, and it is not stored as a plain zip, so download-artifact fails
|
||||
# to extract it and, downloading everything, fails the whole step.
|
||||
- name: Collect the build artefacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: artefacts
|
||||
pattern: pgadmin4-*
|
||||
|
||||
# The layout is the one the publishing scripts and the download site
|
||||
# expect, and is what the wrapper's stage-create verb creates on the
|
||||
# staging server.
|
||||
# The Debian architecture comes from the package filename rather than a
|
||||
# table, since dpkg already encodes it there; the codename does not, so
|
||||
# that mapping is explicit.
|
||||
- name: Assemble the staging tree
|
||||
env:
|
||||
LOCATION: ${{ needs.location.outputs.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Flat, beside apt/ and yum/, which is how both the staging server
|
||||
# and the snapshots tree are laid out. The docs/macos/pip/source/
|
||||
# windows subdivision belongs to a published release, and it is the
|
||||
# promotion step that sorts files into it on the way there.
|
||||
# Explicitly, rather than trusting the checkout to have cleaned it.
|
||||
# This runs on a self-hosted runner whose workspace persists between
|
||||
# jobs, and a previous run's tree left behind here is indistinguishable
|
||||
# from this one's until something downstream has to choose between
|
||||
# them.
|
||||
rm -rf stage
|
||||
|
||||
STAGE="stage/${LOCATION}"
|
||||
mkdir -p "${STAGE}/yum"
|
||||
|
||||
# An artefact can be legitimately absent: the Windows signing host is
|
||||
# allowed to be offline, and a failed matrix leg is reported by its
|
||||
# own job rather than by a confusing cp error here.
|
||||
copy_into() {
|
||||
DEST=$1; shift
|
||||
for SRC in "$@"; do
|
||||
[ -e "${SRC}" ] || continue
|
||||
mkdir -p "${DEST}"
|
||||
cp "${SRC}" "${DEST}/"
|
||||
done
|
||||
}
|
||||
|
||||
codename_for() {
|
||||
case "$1" in
|
||||
debian-12) echo bookworm ;;
|
||||
debian-13) echo trixie ;;
|
||||
ubuntu-2204) echo jammy ;;
|
||||
ubuntu-2404) echo noble ;;
|
||||
ubuntu-2604) echo resolute ;;
|
||||
*) echo "Unknown Debian build name: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for DIR in artefacts/pgadmin4-deb-build-output-*; do
|
||||
[ -d "${DIR}" ] || continue
|
||||
CODENAME=$(codename_for "${DIR##*-output-}")
|
||||
for DEB in "${DIR}"/*.deb; do
|
||||
ARCH=$(basename "${DEB}" .deb); ARCH=${ARCH##*_}
|
||||
TARGET="${STAGE}/apt/${CODENAME}/dists/pgadmin4/main/binary-${ARCH}"
|
||||
mkdir -p "${TARGET}"
|
||||
cp "${DEB}" "${TARGET}/"
|
||||
done
|
||||
done
|
||||
|
||||
# RPM trees are named for the distribution, which the build name
|
||||
# already carries: el-9 becomes redhat/rhel-9-x86_64.
|
||||
for DIR in artefacts/pgadmin4-rpm-build-output-*; do
|
||||
[ -d "${DIR}" ] || continue
|
||||
NAME=${DIR##*-output-}
|
||||
case "${NAME}" in
|
||||
el-*) FAMILY=redhat; TREE="rhel-${NAME#el-}" ;;
|
||||
fedora-*) FAMILY=fedora; TREE="${NAME}" ;;
|
||||
*) echo "Unknown RPM build name: ${NAME}" >&2; exit 1 ;;
|
||||
esac
|
||||
# Every RPM for a distribution goes in that distribution's
|
||||
# tree, including the noarch ones. Taking the directory from the
|
||||
# package's own architecture would file pgadmin4 and
|
||||
# pgadmin4-web, both noarch, under rhel-9-noarch, where no
|
||||
# client looks: dnf asks for <name>-<version>-$basearch. The
|
||||
# RPM matrix is x86_64 only for now, so this is a constant; when
|
||||
# aarch64 arrives it becomes a property of the build.
|
||||
TARGET="${STAGE}/yum/${FAMILY}/${TREE}-x86_64"
|
||||
mkdir -p "${TARGET}"
|
||||
cp "${DIR}"/*.rpm "${TARGET}/"
|
||||
done
|
||||
|
||||
copy_into "${STAGE}" artefacts/pgadmin4-tarball-build-output/*
|
||||
copy_into "${STAGE}" artefacts/pgadmin4-python-build-output/*
|
||||
# The version is read once here and used for the documentation
|
||||
# filenames, which the build leaves unversioned as pgadmin4.pdf and
|
||||
# pgAdmin4.epub whilst the download site has always carried
|
||||
# pgadmin4-<version>.pdf and .epub.
|
||||
VERSION=$(python3 -c "import sys; sys.path.insert(0, 'web'); import version; print(version.APP_VERSION)")
|
||||
|
||||
for SRC in artefacts/pgadmin4-pdf-build-output/*.pdf; do
|
||||
[ -e "${SRC}" ] && cp "${SRC}" "${STAGE}/pgadmin4-${VERSION}.pdf"
|
||||
done
|
||||
for SRC in artefacts/pgadmin4-epub-build-output/*.epub; do
|
||||
[ -e "${SRC}" ] && cp "${SRC}" "${STAGE}/pgadmin4-${VERSION}.epub"
|
||||
done
|
||||
|
||||
# The docs tarball is what load-docs.sh unpacks on the web server, so
|
||||
# it ships as a tarball rather than as the directory of files the
|
||||
# build produces.
|
||||
if [ -d artefacts/pgadmin4-html-build-output ]; then
|
||||
# Under a pgadmin4-<version>-docs/ directory, not flat. The
|
||||
# web server's load-docs.sh unpacks this into the docs root
|
||||
# and then hands docloader that directory name, so a flat
|
||||
# archive would scatter several hundred files across the root
|
||||
# and then fail on a directory that does not exist. Every
|
||||
# released tarball has carried that top level; the tar here
|
||||
# had not.
|
||||
DOCS_DIR="pgadmin4-${VERSION}-docs"
|
||||
rm -rf "artefacts/${DOCS_DIR}"
|
||||
mv artefacts/pgadmin4-html-build-output "artefacts/${DOCS_DIR}"
|
||||
tar -czf "${STAGE}/pgadmin4-${VERSION}-docs.tar.gz" \
|
||||
-C artefacts "${DOCS_DIR}"
|
||||
fi
|
||||
|
||||
# At the root of yum/, beside the family directories, which is
|
||||
# where the README instructions tell people to fetch them from.
|
||||
copy_into "${STAGE}/yum" artefacts/pgadmin4-repo-rpm-build-output/*
|
||||
|
||||
copy_into "${STAGE}" artefacts/pgadmin4-macos-snapshot-*/*
|
||||
copy_into "${STAGE}" artefacts/pgadmin4-windows-snapshot/*
|
||||
|
||||
find "${STAGE}" -type f | sort
|
||||
|
||||
- name: Check the keys are present
|
||||
if: inputs.publish
|
||||
env:
|
||||
STAGING: ${{ vars.STAGING_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for TOOL in ssh rsync; do
|
||||
command -v "${TOOL}" >/dev/null || {
|
||||
echo "This runner has no ${TOOL}." >&2; exit 1; }
|
||||
done
|
||||
|
||||
# These three variables reach option positions. The target is the
|
||||
# first positional argument to ssh, and the key paths are spliced
|
||||
# into the -e string that rsync splits on whitespace before exec'ing
|
||||
# it, so a value beginning with a hyphen, or one carrying a space,
|
||||
# would be read as an option or as a second word rather than as the
|
||||
# host or the path it is meant to be. Only an administrator can set
|
||||
# a repository variable, which makes this defence in depth rather
|
||||
# than a live hole, but it is checked here, once, rather than at
|
||||
# each point of use; the -- in the publish step below closes the
|
||||
# same gap from the other end.
|
||||
TARGET_RE='^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9.-]*)?$'
|
||||
printf '%s' "${STAGING}" | grep -Eq "${TARGET_RE}" || {
|
||||
echo "STAGING_SSH_TARGET is not a [user@]host." >&2; exit 1; }
|
||||
|
||||
for KEY in "${PUBLISH_KEY/#\~/$HOME}" "${UPLOAD_KEY/#\~/$HOME}"; do
|
||||
printf '%s' "${KEY}" | grep -Eq '^/[A-Za-z0-9._/-]+$' || {
|
||||
echo "Not a plain absolute path: ${KEY}" >&2; exit 1; }
|
||||
if [ ! -r "${KEY}" ]; then
|
||||
echo "Missing or unreadable SSH key: ${KEY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A public key is readable too, and pointing -i at one fails
|
||||
# later inside ssh with a message that does not mention which
|
||||
# file was wrong. Both halves of a pair sit in the same
|
||||
# directory with names one suffix apart, so this is an easy
|
||||
# thing to configure wrongly and a cheap thing to catch.
|
||||
if head -c 4 "${KEY}" | grep -q '^ssh-'; then
|
||||
echo "${KEY} is a public key; -i needs the private one." >&2
|
||||
echo "Drop the .pub suffix from the variable that names it." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ! -r ~/.ssh/known_hosts ]; then
|
||||
echo "This runner has no ~/.ssh/known_hosts, so host key" >&2
|
||||
echo "checking would fail. Add the servers' host keys." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish the candidate
|
||||
if: inputs.publish
|
||||
env:
|
||||
STAGING: ${{ vars.STAGING_SSH_TARGET }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${STAGING}" ]; then
|
||||
echo "The STAGING_SSH_TARGET variable is not set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One directory came out of assemble, named for the date. An explicit
|
||||
# location overrides it so that a test run does not land on the
|
||||
# build the buildfarm is still writing on the same server.
|
||||
# assemble produces exactly one directory, named for the date.
|
||||
# Anything else means the artefact is not what this step thinks it
|
||||
# is, and guessing which one to publish is not a decision to make at
|
||||
# three in the morning.
|
||||
if [ "$(find stage -mindepth 1 -maxdepth 1 -type d | wc -l)" != "1" ]; then
|
||||
echo "Expected one directory in the build artefact:" >&2
|
||||
ls -l stage >&2
|
||||
exit 1
|
||||
fi
|
||||
BUILT=$(basename "$(find stage -mindepth 1 -maxdepth 1 -type d)")
|
||||
DEST=${LOCATION:-${BUILT}}
|
||||
echo "Publishing ${BUILT} as ${DEST}"
|
||||
|
||||
publish() {
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -- "${STAGING}" "$@"
|
||||
}
|
||||
|
||||
publish stage-create "${DEST}"
|
||||
|
||||
# No --delete, deliberately: rrsync refuses it anyway, and a
|
||||
# publishing step that can remove things is a publishing step that
|
||||
# can remove the wrong things.
|
||||
rsync -rlt --safe-links --no-specials --no-devices \
|
||||
-e "ssh -i ${UPLOAD_KEY/#\~/$HOME} -o BatchMode=yes -o StrictHostKeyChecking=yes" \
|
||||
"stage/${BUILT}/" "${STAGING}:${DEST}/"
|
||||
|
||||
# Signing before indexing: the metadata has to describe the signed
|
||||
# packages rather than the unsigned ones just uploaded.
|
||||
publish stage-sign "${DEST}"
|
||||
publish stage-index-apt "${DEST}"
|
||||
publish stage-index-yum "${DEST}"
|
||||
|
||||
# Installing from the candidate, which is the only check that proves a user
|
||||
# could have. It gates nothing automatically yet, promotion still being a
|
||||
# human decision, but a red result here is a candidate that must not be
|
||||
# promoted.
|
||||
acceptance:
|
||||
needs: [location, assemble]
|
||||
if: inputs.publish
|
||||
uses: ./.github/workflows/acceptance-test.yml
|
||||
with:
|
||||
base-url: https://developer.pgadmin.org/builds/${{ needs.location.outputs.name }}
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -13,6 +17,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -162,7 +170,7 @@ jobs:
|
||||
- name: Build the JS bundle
|
||||
run: |
|
||||
cd web
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
yarn run bundle
|
||||
|
||||
- name: Run the tests
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -16,6 +20,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -172,7 +180,7 @@ jobs:
|
||||
- name: Build the JS bundle
|
||||
run: |
|
||||
cd web
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
yarn run bundle
|
||||
|
||||
- name: Run the tests
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install Node modules
|
||||
run: |
|
||||
cd web
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
|
||||
- name: Run the tests
|
||||
run: |
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -24,6 +28,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -17,6 +21,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -24,6 +32,11 @@ on:
|
||||
- '.github/workflows/run-python-tests-pg.yml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pg_timetable_version:
|
||||
description: 'pg_timetable version to install for the tests'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
|
||||
@@ -31,6 +44,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
run-python-tests-pg:
|
||||
env:
|
||||
PG_TIMETABLE_VERSION: ${{ inputs.pg_timetable_version || '6.3.0' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -157,6 +172,38 @@ jobs:
|
||||
|
||||
psql postgres -p 59${{ matrix.pgver }} -c 'CREATE ROLE postgres SUPERUSER LOGIN;'
|
||||
|
||||
# pg_timetable tables are created by the agent and need the agent to test run_now.
|
||||
- name: Install and start pg_timetable on Linux
|
||||
if: ${{ matrix.os == 'ubuntu-22.04' }}
|
||||
run: |
|
||||
wget --quiet -O /tmp/pg_timetable.tar.gz https://github.com/cybertec-postgresql/pg_timetable/releases/download/v${{ env.PG_TIMETABLE_VERSION }}/pg_timetable_Linux_x86_64.tar.gz
|
||||
tar -xzf /tmp/pg_timetable.tar.gz -C /tmp --strip-components=1
|
||||
/tmp/pg_timetable --init --clientname=runner "host=/var/run/postgresql port=59${{ matrix.pgver }} dbname=postgres user=postgres"
|
||||
nohup /tmp/pg_timetable --clientname=runner "host=/var/run/postgresql port=59${{ matrix.pgver }} dbname=postgres user=postgres" > /tmp/pg_timetable.log 2>&1 &
|
||||
echo $! > /tmp/pg_timetable.pid
|
||||
sleep 2
|
||||
|
||||
- name: Install and start pg_timetable on macOS
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
run: |
|
||||
curl -sSL -o /tmp/pg_timetable.tar.gz https://github.com/cybertec-postgresql/pg_timetable/releases/download/v${{ env.PG_TIMETABLE_VERSION }}/pg_timetable_Darwin_arm64.tar.gz
|
||||
tar -xzf /tmp/pg_timetable.tar.gz -C /tmp --strip-components=1
|
||||
/tmp/pg_timetable --init --clientname=runner "host=/tmp port=59${{ matrix.pgver }} dbname=postgres user=postgres"
|
||||
nohup /tmp/pg_timetable --clientname=runner "host=/tmp port=59${{ matrix.pgver }} dbname=postgres user=postgres" > /tmp/pg_timetable.log 2>&1 &
|
||||
echo $! > /tmp/pg_timetable.pid
|
||||
sleep 2
|
||||
|
||||
- name: Install and start pg_timetable on Windows
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PGPASSWORD = 'postgres'
|
||||
curl.exe -sSL -o pg_timetable.zip https://github.com/cybertec-postgresql/pg_timetable/releases/download/v${{ env.PG_TIMETABLE_VERSION }}/pg_timetable_Windows_x86_64.zip
|
||||
Expand-Archive -Path pg_timetable.zip -DestinationPath .
|
||||
& .\pg_timetable_Windows_x86_64\pg_timetable.exe --init --clientname=runner "postgresql://postgres:postgres@127.0.0.1:59${{ matrix.pgver }}/postgres"
|
||||
Start-Process -FilePath .\pg_timetable_Windows_x86_64\pg_timetable.exe -ArgumentList @('--clientname=runner', "postgresql://postgres:postgres@127.0.0.1:59${{ matrix.pgver }}/postgres") -RedirectStandardError .\pg_timetable_Windows_x86_64\pg_timetable.err -NoNewWindow
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
- name: Install Python dependencies on Linux and macOS
|
||||
if: ${{ matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04' }}
|
||||
run: make install-python-testing
|
||||
@@ -332,6 +379,19 @@ jobs:
|
||||
python web\regression\runtests.py --exclude feature_tests
|
||||
shell: cmd
|
||||
|
||||
- name: Stop pg_timetable on Linux and macOS
|
||||
if: always() && (matrix.os == 'macos-latest' || matrix.os == 'ubuntu-22.04')
|
||||
run: |
|
||||
if [ -f /tmp/pg_timetable.pid ]; then
|
||||
kill $(cat /tmp/pg_timetable.pid) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Stop pg_timetable on Windows
|
||||
if: always() && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Process -Name pg_timetable -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
|
||||
- name: Archive server log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
@@ -25,6 +29,10 @@ on:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'web/**'
|
||||
# Not the message catalogues: a .po change cannot break a build or a
|
||||
# test, and check-translations already compiles them, which is the
|
||||
# check that would catch a malformed one.
|
||||
- '!web/pgadmin/translations/**'
|
||||
- 'requirements.txt'
|
||||
- 'Makefile'
|
||||
- 'tools/setup-python-env.sh'
|
||||
|
||||
@@ -141,10 +141,21 @@ jobs:
|
||||
echo "=== gatekeeper assessment ==="
|
||||
spctl --assess --type execute --verbose=4 "${APP}"
|
||||
echo "=== stapled notarisation ==="
|
||||
xcrun stapler validate "${APP}"
|
||||
# stapler looks the ticket up in Apple's CloudKit, and a dropped
|
||||
# connection there fails the step with error 68, so it is retried.
|
||||
# A genuinely broken ticket still fails all three attempts.
|
||||
validate() {
|
||||
for attempt in 1 2 3; do
|
||||
xcrun stapler validate "$1" && return 0
|
||||
[ "${attempt}" -lt 3 ] || return 1
|
||||
echo "stapler validate failed; retrying in $((attempt * 30))s"
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
}
|
||||
validate "${APP}"
|
||||
for F in dist/*.dmg; do
|
||||
echo "--- ${F} ---"
|
||||
xcrun stapler validate "${F}"
|
||||
validate "${F}"
|
||||
done
|
||||
|
||||
- name: Archive the disk image and zip
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# pgadmin4-windows-x64-snapshot job.
|
||||
#
|
||||
# This is the one workflow that cannot use a hosted runner. The certificate is
|
||||
# on a Certum hardware token plugged into the machine in Dave's rack, reached
|
||||
# on a Certum hardware token plugged into a machine we host ourselves, reached
|
||||
# through the Windows machine certificate store, and a PIN handler answers the
|
||||
# prompt from the interactive desktop session. None of that can be moved to a
|
||||
# runner GitHub provides.
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
# Nothing here signs anything either, beyond the macOS notarisation and the
|
||||
# Windows Authenticode signature, which have to happen where their credentials
|
||||
# are. The GPG key that signs the RPMs and the repository metadata stays on the
|
||||
# servers: this workflow assembles the tree and uploads it, and asks procyon to
|
||||
# servers: this workflow assembles the tree and uploads it, and asks the
|
||||
# staging server to
|
||||
# index and sign, so a compromised workflow run can ask for a signature but
|
||||
# never holds the means to make one.
|
||||
|
||||
@@ -54,10 +55,26 @@ jobs:
|
||||
# the repo RPMs carry the snapshot's own URL inside them, so if that job and
|
||||
# the assemble job each called date(1) a run straddling midnight would ship a
|
||||
# repository configuration pointing at a snapshot that does not exist.
|
||||
#
|
||||
# It is also where a scheduled run on a fork stops. A fork with Actions
|
||||
# enabled inherits the schedule but none of the signing secrets or
|
||||
# self-hosted runners, so the macOS jobs would fail at the certificate and
|
||||
# the Windows job would queue for a day. Every other job waits on this one,
|
||||
# so skipping it skips the run. A dispatch still runs anywhere.
|
||||
location:
|
||||
if: >-
|
||||
github.event_name != 'schedule'
|
||||
|| github.repository == 'pgadmin-org/pgadmin4'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
name: ${{ steps.pick.outputs.name }}
|
||||
# Whether a location was asked for by name, which decides both what the
|
||||
# published directory is called and whether retention runs. It travels
|
||||
# as a flag so that the steps downstream never need the raw input to
|
||||
# work out what was requested: this job is where that input is checked,
|
||||
# and a value that has not been through the check below has no business
|
||||
# reaching the machine holding the publishing keys.
|
||||
named: ${{ steps.pick.outputs.named }}
|
||||
steps:
|
||||
- id: pick
|
||||
env:
|
||||
@@ -65,6 +82,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NAME="${REQUESTED:-}"
|
||||
if [ -n "${NAME}" ]; then NAMED=yes; else NAMED=no; fi
|
||||
[ -n "${NAME}" ] || NAME=$(date +%Y-%m-%d)
|
||||
|
||||
# Validated here so that everything downstream, including a shell on
|
||||
@@ -81,16 +99,22 @@ jobs:
|
||||
|
||||
echo "Snapshot location: ${NAME}"
|
||||
echo "name=${NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "named=${NAMED}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
deb:
|
||||
needs: location
|
||||
uses: ./.github/workflows/check-deb-build.yml
|
||||
rpm:
|
||||
needs: location
|
||||
uses: ./.github/workflows/check-rpm-build.yml
|
||||
tarball:
|
||||
needs: location
|
||||
uses: ./.github/workflows/check-tarball-build.yml
|
||||
wheel:
|
||||
needs: location
|
||||
uses: ./.github/workflows/check-python-build.yml
|
||||
docs:
|
||||
needs: location
|
||||
uses: ./.github/workflows/check-doc-builds.yml
|
||||
# No helm job here. The chart is not part of the download tree: the
|
||||
# buildfarm pushed it to an OCI registry, which nothing in this workflow
|
||||
@@ -121,7 +145,8 @@ jobs:
|
||||
run: |
|
||||
# Through the environment rather than interpolated into the script.
|
||||
# The location is a dispatch input, so it is text somebody chose, and
|
||||
# ${{ }} substitution happens before the shell ever sees the line.
|
||||
# workflow expression substitution happens before the shell ever sees the
|
||||
# line, so a crafted location would otherwise become shell text.
|
||||
export PGADMIN_REPO_DIR="https://ftp.postgresql.org/pub/pgadmin/pgadmin4/snapshots/${LOCATION}/yum"
|
||||
echo "Repositories will point at ${PGADMIN_REPO_DIR}"
|
||||
pkg/redhat/repo-rpm.sh
|
||||
@@ -134,22 +159,24 @@ jobs:
|
||||
path: dist/*.noarch.rpm
|
||||
|
||||
macos:
|
||||
needs: location
|
||||
uses: ./.github/workflows/snapshot-macos.yml
|
||||
secrets: inherit
|
||||
|
||||
windows:
|
||||
needs: location
|
||||
uses: ./.github/workflows/snapshot-windows.yml
|
||||
secrets: inherit
|
||||
|
||||
assemble:
|
||||
needs: [location, deb, rpm, tarball, wheel, docs, repo-rpm, macos, windows]
|
||||
|
||||
# roadie, not a hosted runner. This job assembles a tree of several
|
||||
# the runner, not a hosted runner. This job assembles a tree of several
|
||||
# hundred megabytes and then, when publishing, sends it to the download
|
||||
# server; splitting the two meant uploading that tree as an artifact and
|
||||
# immediately downloading it again on roadie, having first queued for a
|
||||
# immediately downloading it again on the runner, having first queued for a
|
||||
# hosted runner behind whatever else the organisation was building.
|
||||
runs-on: [self-hosted, roadie]
|
||||
runs-on: [self-hosted, publish]
|
||||
|
||||
# For the variables naming the keys and the target, which live here. The
|
||||
# environment carries no protection rules, so a run that is not publishing
|
||||
@@ -182,7 +209,8 @@ jobs:
|
||||
path: artefacts
|
||||
|
||||
# The layout is the one the publishing scripts and the download site
|
||||
# expect, and is what the wrapper's stage-create verb creates on procyon.
|
||||
# expect, and is what the wrapper's stage-create verb creates on the
|
||||
# staging server.
|
||||
# The Debian architecture comes from the package filename rather than a
|
||||
# table, since dpkg already encodes it there; the codename does not, so
|
||||
# that mapping is explicit.
|
||||
@@ -280,8 +308,18 @@ jobs:
|
||||
# it ships as a tarball rather than as the directory of files the
|
||||
# build produces.
|
||||
if [ -d artefacts/pgadmin4-html-build-output ]; then
|
||||
# Under a pgadmin4-<version>-docs/ directory, not flat. The
|
||||
# web server's load-docs.sh unpacks this into the docs root
|
||||
# and then hands docloader that directory name, so a flat
|
||||
# archive would scatter several hundred files across the root
|
||||
# and then fail on a directory that does not exist. Every
|
||||
# released tarball has carried that top level; the tar here
|
||||
# had not.
|
||||
DOCS_DIR="pgadmin4-${VERSION}-docs"
|
||||
rm -rf "artefacts/${DOCS_DIR}"
|
||||
mv artefacts/pgadmin4-html-build-output "artefacts/${DOCS_DIR}"
|
||||
tar -czf "${STAGE}/pgadmin4-${VERSION}-docs.tar.gz" \
|
||||
-C artefacts/pgadmin4-html-build-output .
|
||||
-C artefacts "${DOCS_DIR}"
|
||||
fi
|
||||
|
||||
# At the root of yum/, beside the family directories, which is
|
||||
@@ -294,10 +332,16 @@ jobs:
|
||||
find "${STAGE}" -type f | sort
|
||||
|
||||
- name: Check the keys are present
|
||||
if: inputs.publish
|
||||
# A scheduled run always publishes, that being the entire point of the
|
||||
# nightly; the input exists so that a dispatched test run does not.
|
||||
# It has to be spelled out because a schedule event carries no inputs
|
||||
# at all, so `inputs.publish` is null rather than false, and the two
|
||||
# publishing steps were quietly skipped on every nightly.
|
||||
if: ${{ github.event_name == 'schedule' || inputs.publish }}
|
||||
env:
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/roadie-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/roadie-upload' }}
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for TOOL in ssh rsync; do
|
||||
@@ -305,7 +349,23 @@ jobs:
|
||||
echo "This runner has no ${TOOL}." >&2; exit 1; }
|
||||
done
|
||||
|
||||
# These three variables reach option positions. The target is the
|
||||
# first positional argument to ssh, and the key paths are spliced
|
||||
# into the -e string that rsync splits on whitespace before exec'ing
|
||||
# it, so a value beginning with a hyphen, or one carrying a space,
|
||||
# would be read as an option or as a second word rather than as the
|
||||
# host or the path it is meant to be. Only an administrator can set
|
||||
# a repository variable, which makes this defence in depth rather
|
||||
# than a live hole, but it is checked here, once, rather than at
|
||||
# each point of use; the -- in the publish step below closes the
|
||||
# same gap from the other end.
|
||||
TARGET_RE='^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9.-]*)?$'
|
||||
printf '%s' "${DOWNLOAD}" | grep -Eq "${TARGET_RE}" || {
|
||||
echo "DOWNLOAD_SSH_TARGET is not a [user@]host." >&2; exit 1; }
|
||||
|
||||
for KEY in "${PUBLISH_KEY/#\~/$HOME}" "${UPLOAD_KEY/#\~/$HOME}"; do
|
||||
printf '%s' "${KEY}" | grep -Eq '^/[A-Za-z0-9._/-]+$' || {
|
||||
echo "Not a plain absolute path: ${KEY}" >&2; exit 1; }
|
||||
if [ ! -r "${KEY}" ]; then
|
||||
echo "Missing or unreadable SSH key: ${KEY}" >&2
|
||||
exit 1
|
||||
@@ -328,12 +388,18 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish the snapshot
|
||||
if: inputs.publish
|
||||
if: ${{ github.event_name == 'schedule' || inputs.publish }}
|
||||
env:
|
||||
DOWNLOAD: ${{ vars.DOWNLOAD_SSH_TARGET }}
|
||||
LOCATION: ${{ inputs.location }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/roadie-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/roadie-upload' }}
|
||||
# From the location job rather than from inputs.location, so the
|
||||
# name reaching the publishing key is the one that job checked the
|
||||
# shape of. The two agree today, which is exactly why it is worth
|
||||
# fixing: the validation sat upstream of this step by coincidence
|
||||
# rather than by construction.
|
||||
LOCATION: ${{ needs.location.outputs.name }}
|
||||
NAMED: ${{ needs.location.outputs.named }}
|
||||
PUBLISH_KEY: ${{ vars.PUBLISH_SSH_KEY || '~/.ssh/pga-publish' }}
|
||||
UPLOAD_KEY: ${{ vars.UPLOAD_SSH_KEY || '~/.ssh/pga-upload' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DOWNLOAD}" ]; then
|
||||
@@ -354,12 +420,20 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
BUILT=$(basename "$(find stage -mindepth 1 -maxdepth 1 -type d)")
|
||||
DEST=${LOCATION:-${BUILT}}
|
||||
# An empty input still means "whatever assemble called it", which is
|
||||
# why this turns on the flag rather than on the name being empty:
|
||||
# the location job fills a blank input in with today's date, so the
|
||||
# name itself no longer says whether anybody asked for one.
|
||||
if [ "${NAMED}" = "yes" ]; then
|
||||
DEST=${LOCATION}
|
||||
else
|
||||
DEST=${BUILT}
|
||||
fi
|
||||
echo "Publishing ${BUILT} as ${DEST}"
|
||||
|
||||
publish() {
|
||||
ssh -i "${PUBLISH_KEY/#\~/$HOME}" -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes "${DOWNLOAD}" "$@"
|
||||
-o StrictHostKeyChecking=yes -- "${DOWNLOAD}" "$@"
|
||||
}
|
||||
|
||||
publish snapshot-create "${DEST}"
|
||||
@@ -384,7 +458,7 @@ jobs:
|
||||
# Skipped when the directory was named by hand, because that is a
|
||||
# test run, and a test run has no business deciding which of the
|
||||
# real snapshots are old enough to delete.
|
||||
if [ -n "${LOCATION}" ]; then
|
||||
if [ "${NAMED}" = "yes" ]; then
|
||||
echo "Named location, so leaving retention alone."
|
||||
else
|
||||
publish snapshot-purge
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# This workflow requires the following configuration in Github
|
||||
#
|
||||
# Variables:
|
||||
# SONARQUBE_PROJECT_KEY - The project key in SonarQube
|
||||
#
|
||||
# Secrets:
|
||||
# SONARQUBE_TOKEN - SonarQube API token
|
||||
# SONARQUBE_HOST - The URL of the SonarQube host
|
||||
|
||||
name: SonarQube scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
concurrency:
|
||||
group: sonarqube
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
run-sonarqube-scan:
|
||||
# Only run if the project key is set
|
||||
if: vars.SONARQUBE_PROJECT_KEY != null
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create the scan properties file
|
||||
run: |
|
||||
cat <<EOF > sonar-project.properties
|
||||
sonar.projectKey=${{ vars.SONARQUBE_PROJECT_KEY }}
|
||||
sonar.projectName=pgAdmin 4
|
||||
sonar.projectVersion=%VERSION%
|
||||
|
||||
# Ignore templates and SQL scripts as they confuse the scanner
|
||||
sonar.exclusions=**/templates/**/*, **/*.sql
|
||||
|
||||
# Let SonarQube know where tests can be found
|
||||
sonar.test.inclusions=**/tests/**, web/regression
|
||||
|
||||
# Python compatibility
|
||||
sonar.python.version=3.9, 3.10, 3.11, 3.12, 3.13, 3.14
|
||||
EOF
|
||||
|
||||
APP_RELEASE=`grep "^APP_RELEASE" web/config.py | cut -d"=" -f2 | sed 's/ //g'`
|
||||
APP_REVISION=`grep "^APP_REVISION" web/config.py | cut -d"=" -f2 | sed 's/ //g'`
|
||||
APP_LONG_VERSION=${APP_RELEASE}.${APP_REVISION}
|
||||
sed -i "s/%VERSION%/${APP_LONG_VERSION}/g" sonar-project.properties
|
||||
|
||||
- name: SonarQube Scan
|
||||
uses: sonarsource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST }}
|
||||
@@ -42,7 +42,7 @@ RUN --mount=type=bind,source=.git,target=/pgadmin4/.git \
|
||||
export CPPFLAGS="-DPNG_ARM_NEON_OPT=0" && \
|
||||
npm install -g corepack && \
|
||||
corepack enable && \
|
||||
yarn install && \
|
||||
yarn install --immutable && \
|
||||
yarn run bundle && \
|
||||
rm -rf yarn.lock \
|
||||
package.json \
|
||||
@@ -59,8 +59,15 @@ RUN --mount=type=bind,source=.git,target=/pgadmin4/.git \
|
||||
FROM python:3-alpine AS env-builder
|
||||
|
||||
# Install dependencies
|
||||
#
|
||||
# libpq is a runtime dependency of psycopg's C extension: psycopg[c] is built
|
||||
# from source against postgresql-dev, but that is removed with build-deps
|
||||
# below, so libpq.so.5 must be installed separately to survive. Without it,
|
||||
# importing psycopg (which the Sphinx docs build does, via config.py) fails
|
||||
# with "no pq wrapper available".
|
||||
RUN apk add --no-cache \
|
||||
make && \
|
||||
make \
|
||||
libpq && \
|
||||
apk add --no-cache --virtual build-deps \
|
||||
build-base \
|
||||
openssl-dev \
|
||||
@@ -77,6 +84,13 @@ RUN python3 -m venv --system-site-packages --without-pip /venv && \
|
||||
/venv/bin/python3 -m pip install --no-cache-dir -r requirements.txt && \
|
||||
apk del --no-cache build-deps
|
||||
|
||||
# Remove the files pgAdmin never uses. The script reads the language list from
|
||||
# web/config.py, so it is copied in separately to avoid a change to it
|
||||
# reinstalling the requirements above.
|
||||
COPY pkg/prune_site_packages.py /prune/pkg/
|
||||
COPY web/config.py /prune/web/
|
||||
RUN /venv/bin/python3 /prune/pkg/prune_site_packages.py /venv
|
||||
|
||||
#########################################################################
|
||||
# Now, create a documentation build container for the Sphinx docs
|
||||
#########################################################################
|
||||
|
||||
@@ -25,7 +25,7 @@ appbundle:
|
||||
./pkg/mac/build.sh $(BUILD_OPTS)
|
||||
|
||||
install-node:
|
||||
cd web && yarn install
|
||||
cd web && yarn install --immutable
|
||||
|
||||
install-python:
|
||||
./tools/setup-python-env.sh
|
||||
|
||||
@@ -100,6 +100,10 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
* Move the switch next to *Only schemas* to limit the back up to schema-level
|
||||
database objects.
|
||||
|
||||
* Move the switch next to *Only statistics* to the *Yes* position to limit
|
||||
the back up to optimizer statistics. **Note:** This option is visible only
|
||||
for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Blobs* to the *No* position to exclude large
|
||||
objects in the backup.
|
||||
|
||||
@@ -143,6 +147,22 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
Table access methods. **Note:** This option is visible only for
|
||||
database server greater than or equal to 15.
|
||||
|
||||
* Move the switch next to *Row security policies* to the *Yes* position to
|
||||
exclude row security policies. **Note:** This option is visible only for
|
||||
database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Data* to the *Yes* position to exclude data from
|
||||
the backup. **Note:** This option is visible only for database server
|
||||
greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Schema* to the *Yes* position to exclude schema
|
||||
(the data definitions) from the backup. **Note:** This option is visible
|
||||
only for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Statistics* to the *Yes* position to exclude
|
||||
optimizer statistics. **Note:** This option is visible only for database
|
||||
server greater than or equal to 18.
|
||||
|
||||
.. image:: images/backup_queries.png
|
||||
:alt: Queries option on backup dialog
|
||||
:align: center
|
||||
@@ -259,6 +279,15 @@ tab to provide other backup options.
|
||||
position to include a statement that will use a SET SESSION AUTHORIZATION
|
||||
command to determine object ownership (instead of an ALTER OWNER command).
|
||||
|
||||
* Move the switch next to *Dump statistics* to the *Yes* position to include
|
||||
optimizer statistics in the backup. It cannot be combined with *Only
|
||||
data*, *Only schemas* or *Do not save* *Statistics*. **Note:** This option
|
||||
is visible only for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Sequence data* to the *Yes* position to include
|
||||
sequence data that would normally be excluded. **Note:** This option is
|
||||
visible only for database server greater than or equal to 18.
|
||||
|
||||
* Use the *Exclude schema* field to not dump schemas whose name matches
|
||||
pattern.
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
|
||||
* Move the switch next to *Only roles* to limit the back up to roles only.
|
||||
|
||||
* Move the switch next to *Only statistics* to the *Yes* position to limit
|
||||
the back up to optimizer statistics. **Note:** This option is visible only
|
||||
for database server greater than or equal to 18.
|
||||
|
||||
.. image:: images/backup_server_do_not_save.png
|
||||
:alt: Do not save option on backup server dialog
|
||||
:align: center
|
||||
@@ -88,6 +92,22 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
Table access methods. **Note:** This option is visible only for
|
||||
database server greater than or equal to 15.
|
||||
|
||||
* Move the switch next to *Row security policies* to the *Yes* position to
|
||||
exclude row security policies. **Note:** This option is visible only for
|
||||
database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Data* to the *Yes* position to exclude data from
|
||||
the backup. **Note:** This option is visible only for database server
|
||||
greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Schema* to the *Yes* position to exclude schema
|
||||
(the data definitions) from the backup. **Note:** This option is visible
|
||||
only for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Statistics* to the *Yes* position to exclude
|
||||
optimizer statistics. **Note:** This option is visible only for database
|
||||
server greater than or equal to 18.
|
||||
|
||||
.. image:: images/backup_server_queries.png
|
||||
:alt: Queries option on backup server dialog
|
||||
:align: center
|
||||
@@ -171,6 +191,15 @@ tab to provide other backup options.
|
||||
position to include a statement that will use a SET SESSION AUTHORIZATION
|
||||
command to determine object ownership (instead of an ALTER OWNER command).
|
||||
|
||||
* Move the switch next to *Dump statistics* to the *Yes* position to include
|
||||
optimizer statistics in the backup. It cannot be combined with *Only
|
||||
data*, *Only schemas* or *Do not save* *Statistics*. **Note:** This option
|
||||
is visible only for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Sequence data* to the *Yes* position to include
|
||||
sequence data that would normally be excluded. **Note:** This option is
|
||||
visible only for database server greater than or equal to 18.
|
||||
|
||||
* Use the *Exclude database* field to not dump databases whose name matches
|
||||
pattern.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.. _change_password_dialog:
|
||||
|
||||
*******************************
|
||||
`Change Password Dialog`:index:
|
||||
*******************************
|
||||
**************************************
|
||||
`Change Server Password Dialog`:index:
|
||||
**************************************
|
||||
|
||||
It is a good policy to routinely change your password to protect data, even in
|
||||
what you may consider a 'safe' environment. In the workplace, failure to apply
|
||||
@@ -30,7 +30,9 @@ comprehensive list and they **will not guarantee security**.
|
||||
:alt: Change database password dialog
|
||||
:align: center
|
||||
|
||||
Use the *Change Password* dialog to change your password:
|
||||
Use the *Change Server Password* dialog to change the password of the role that
|
||||
pgAdmin uses to connect to the database server, rather than the password you use
|
||||
to log in to pgAdmin itself:
|
||||
|
||||
* The name displayed in the *User* field is the role for which you are modifying
|
||||
the password; it is the role that is associated with the server connection
|
||||
@@ -39,7 +41,7 @@ Use the *Change Password* dialog to change your password:
|
||||
* Enter the desired password for in the *New Password* field.
|
||||
* Re-enter the new password in the *Confirm Password* field.
|
||||
|
||||
Click the *Save* button to change your password.
|
||||
Click the *Change* button to change your password.
|
||||
|
||||
Click the *Close* button to exit without changing your password.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.. _change_user_password:
|
||||
|
||||
************************************
|
||||
`Change User Password Dialog`:index:
|
||||
************************************
|
||||
***************************************
|
||||
`Change pgAdmin Password Dialog`:index:
|
||||
***************************************
|
||||
|
||||
It is a good policy to routinely change your password to protect data, even in
|
||||
what you may consider a 'safe' environment. In the workplace, failure to apply
|
||||
@@ -30,11 +30,18 @@ comprehensive list and they **will not guarantee security**.
|
||||
:alt: Change current user password dialog
|
||||
:align: center
|
||||
|
||||
Use the *Change Password* dialog to change your password:
|
||||
Use the *Change pgAdmin Password* dialog to change the password you use to log
|
||||
in to pgAdmin itself, rather than the password of a database server connection:
|
||||
|
||||
* Enter your existing password in the *Current Password* field.
|
||||
* Enter the desired password for in the *New Password* field.
|
||||
* Re-enter the new password in the *Confirm Password* field.
|
||||
|
||||
Click the *Change Password* button to change your password; click *Close* to
|
||||
exit the dialog.
|
||||
Click the *Change* button to change your password; click *Close* to exit the
|
||||
dialog.
|
||||
|
||||
Changing your password signs out all of your other active sessions, so anyone
|
||||
using your account elsewhere must sign in again with the new password. The
|
||||
session you change it in stays signed in. When an administrator resets your
|
||||
password from the :ref:`User Management <user_management>` page, all of your
|
||||
sessions are signed out.
|
||||
@@ -5,8 +5,8 @@
|
||||
*************************************
|
||||
|
||||
A PostgreSQL server can be deployed on the Amazon, Azure, Google cloud
|
||||
using this module. In future more cloud provider options will be
|
||||
available.
|
||||
or pgEdge Starfleet using this module. In future more cloud provider
|
||||
options will be available.
|
||||
|
||||
To launch the *Cloud Deployment...* tool, right click on the *Server Group* or
|
||||
*Server* of the tree control, and select *Deploy a Cloud Instance* from the
|
||||
@@ -18,3 +18,4 @@ To launch the *Cloud Deployment...* tool, right click on the *Server Group* or
|
||||
cloud_aws_rds
|
||||
cloud_azure_database
|
||||
cloud_google_cloud_sql
|
||||
cloud_pgedge_starfleet
|
||||
@@ -0,0 +1,102 @@
|
||||
.. _cloud_pgedge_starfleet:
|
||||
|
||||
************************************************
|
||||
`pgEdge Starfleet Deployment`:index:
|
||||
************************************************
|
||||
|
||||
To deploy a PostgreSQL database on pgEdge Starfleet, follow the steps below.
|
||||
pgAdmin creates a *Managed* database, which Starfleet hosts for you.
|
||||
|
||||
.. image:: images/cloud_provider_for_postgresql.png
|
||||
:alt: Cloud Deployment Provider
|
||||
:align: center
|
||||
|
||||
Once you launch the tool, select the pgEdge Starfleet option and click on the
|
||||
*Next* button to proceed.
|
||||
|
||||
.. image:: images/cloud_starfleet_credentials.png
|
||||
:alt: pgEdge Starfleet credentials
|
||||
:align: center
|
||||
|
||||
In the Credentials step, enter the *Auth ID* and *Auth secret* of a Starfleet
|
||||
API client. pgAdmin checks them with Starfleet when you click *Next*.
|
||||
|
||||
.. note:: Create the API client in the pgEdge Starfleet console, under
|
||||
*Settings > API Clients*. The Auth secret is shown only when the client is
|
||||
created, so keep a copy of it. See the
|
||||
`pgEdge Starfleet documentation <https://docs.pgedge.com/pgedge-starfleet/>`_
|
||||
for details.
|
||||
|
||||
.. image:: images/cloud_starfleet_instance.png
|
||||
:alt: pgEdge Starfleet instance specification
|
||||
:align: center
|
||||
|
||||
Use the fields from the Instance Specification step to specify the database
|
||||
details.
|
||||
|
||||
* Use the *Database name* field to name the database. The name may contain
|
||||
lowercase letters and digits only, must start with a letter, and can be up
|
||||
to 50 characters long.
|
||||
|
||||
* Use the *Display name* field to optionally set a friendlier name, up to 25
|
||||
characters long.
|
||||
|
||||
* Select the location to deploy the database to from the *Region* field.
|
||||
|
||||
* Use the *Size* field to select the size of the database.
|
||||
|
||||
* Use the *PostgreSQL version* field to select the PostgreSQL version.
|
||||
|
||||
* Use the *Allowed IP addresses* field to list the IPv4 addresses or CIDR
|
||||
ranges that may connect to the database, separated by commas. The field is
|
||||
prefilled with your public IP address. At least one and at most 50 entries
|
||||
are required; IPv6 addresses are not accepted. Without a matching entry the
|
||||
database will not accept connections from pgAdmin.
|
||||
|
||||
* Use the *Connect as* field to choose whether pgAdmin connects as *admin*
|
||||
(the database administrator, which is not a superuser) or *app* (an
|
||||
application user).
|
||||
|
||||
.. image:: images/cloud_starfleet_database.png
|
||||
:alt: pgEdge Starfleet database details
|
||||
:align: center
|
||||
|
||||
In the Database Details step, use the *pgAdmin server group* field to select
|
||||
the server group the new server will be registered in.
|
||||
|
||||
.. image:: images/cloud_starfleet_review.png
|
||||
:alt: pgEdge Starfleet review
|
||||
:align: center
|
||||
|
||||
Finally, review the summary in the Review step and click *Finish* to start the
|
||||
deployment. The progress of the deployment is shown in the same way as for the
|
||||
other cloud providers, and the new server is added to the *Object Explorer*
|
||||
when the database is available. pgAdmin connects with *sslmode* set to
|
||||
*require* and *gssencmode* set to *disable*.
|
||||
|
||||
If the deployment fails after the database has been created, for example
|
||||
because pgEdge Starfleet reports it as failed or it does not become available
|
||||
within 30 minutes, pgAdmin removes the server from the *Object Explorer* but
|
||||
does not delete the database. Check the pgEdge Starfleet console, and delete
|
||||
the database there if it is not needed.
|
||||
|
||||
.. image:: images/cloud_starfleet_password.png
|
||||
:alt: pgEdge Starfleet database password
|
||||
:align: center
|
||||
|
||||
pgEdge Starfleet generates the database password. When the deployment
|
||||
finishes, pgAdmin shows it once in the *pgEdge Starfleet database password*
|
||||
dialog, together with the host and user name.
|
||||
|
||||
* Use the *Copy* button to copy the password to the clipboard.
|
||||
|
||||
* Use the *Save password* button to save the password with the server. This
|
||||
button is only shown if saving passwords is permitted by the
|
||||
:ref:`ALLOW_SAVE_PASSWORD <config_py>` setting.
|
||||
|
||||
The password is not shown again in pgAdmin after the dialog is closed. If you
|
||||
lose it, reset it from the pgEdge Starfleet console.
|
||||
|
||||
.. note:: pgAdmin contacts the Starfleet API at the address given by the
|
||||
*STARFLEET_API_URL* configuration setting, which defaults to
|
||||
*https://api.pgedge.com*. The address must use HTTPS.
|
||||
@@ -1,8 +1,8 @@
|
||||
.. _config_py:
|
||||
|
||||
***************************
|
||||
`The config.py File`:index:
|
||||
***************************
|
||||
*****************************
|
||||
`Configuration Files`:index:
|
||||
*****************************
|
||||
|
||||
There are multiple configuration files that are read at startup by pgAdmin. These
|
||||
files are used for configuration options that:
|
||||
@@ -32,6 +32,12 @@ The configuration files are as follows:
|
||||
and may be created by users in the same directory as ``config.py`` if
|
||||
needed.
|
||||
|
||||
.. note:: On macOS, ``config.py`` is located inside the application bundle
|
||||
(``pgAdmin 4.app/Contents/Resources/web/``), which is replaced in its
|
||||
entirety when pgAdmin is upgraded, so a ``config_local.py`` file created
|
||||
there will be lost. Use ``config_system.py`` instead, which is stored
|
||||
outside the application bundle.
|
||||
|
||||
* ``config_system.py``: This file is read after ``config_local.py`` and is
|
||||
intended for system administrators to include settings that are configured
|
||||
system-wide from a secure location that users cannot normally modify and that
|
||||
|
||||
@@ -157,8 +157,10 @@ of the `Preferences JSON file <https://www.pgadmin.org/docs/pgadmin4/latest/pref
|
||||
|
||||
Override the default file path for the pgadmin configurations file.This can be used while provisioning
|
||||
container with read only root file system to achieve a more secure pgadmin4 deployment for kubernetes.
|
||||
Note that if you are externally mapping this file, then environment variables passed using *PGADMIN_CONFIG_*
|
||||
suffix will be ignored.
|
||||
Note that if you are externally mapping this file, then environment variables
|
||||
passed using *PGADMIN_CONFIG_* suffix will be ignored only when that file
|
||||
exists and is non-empty. A missing or empty file is generated with those
|
||||
values.
|
||||
|
||||
**PGPASS_FILE**
|
||||
|
||||
@@ -167,6 +169,24 @@ Set this variable to the path of a pgpass file if you want to supply passwords
|
||||
for the servers added in pgAdmin. On startup, the file is copied into the
|
||||
container and used as the ``.pgpass`` file.
|
||||
|
||||
**PUID**
|
||||
|
||||
*Default: 5050*
|
||||
|
||||
The user ID that pgAdmin runs as. This is only used when the container is
|
||||
started as root (for example with ``--user root``), in which case the
|
||||
*pgadmin* user is given this UID, the ownership of */var/lib/pgadmin* is
|
||||
changed to match, and pgAdmin is then run as that user rather than as root.
|
||||
It is ignored when the container is started as any other user, which is the
|
||||
default. The value must be numeric, and may not be 0.
|
||||
|
||||
**PGID**
|
||||
|
||||
*Default: 0*
|
||||
|
||||
The group ID that pgAdmin runs as. As with *PUID*, this is only used when the
|
||||
container is started as root.
|
||||
|
||||
**GUNICORN_ACCESS_LOGFILE**
|
||||
|
||||
*Default: -* (stdout)
|
||||
@@ -195,20 +215,62 @@ where it may be increased.
|
||||
|
||||
This is a variable prefix that can be used to override any of the configuration
|
||||
options in pgAdmin's *config.py* file. Add the *PGADMIN_CONFIG_* prefix to any
|
||||
variable name from *config.py* and give the value in the format 'string value'
|
||||
for strings, True/False for booleans or 123 for numbers. See below for an
|
||||
example.
|
||||
variable name from *config.py* and give the value as a Python expression:
|
||||
``'string value'`` (including the quotes) for strings, ``True`` or ``False``
|
||||
for booleans, or ``123`` for numbers. See below for an example.
|
||||
|
||||
Settings are written to */pgadmin4/config_distro.py* within the container, which
|
||||
is read after */pgadmin4/config.py* and before */pgadmin4/config_local.py*.
|
||||
Any settings given will therefore override anything in config.py, but can be
|
||||
overridden by settings in config_local.py.
|
||||
*PGADMIN_DEFAULT_* variables, including *PGADMIN_DEFAULT_EMAIL* and
|
||||
*PGADMIN_DEFAULT_PASSWORD*, are ordinary text and take no Python literal
|
||||
quotes. *PGADMIN_CONFIG_* values are written as Python code into
|
||||
*/pgadmin4/config_distro.py*, or into the file named by
|
||||
*PGADMIN_CUSTOM_CONFIG_DISTRO_FILE* if that is set. The values ``true`` and
|
||||
``false``, in any letter case, are written as ``True`` and ``False``. A bare
|
||||
hostname is a Python name, so ``MAIL_SERVER = mail.example.com`` raises
|
||||
``NameError`` at startup.
|
||||
|
||||
Settings are only written to */pgadmin4/config_distro.py* once, typically on
|
||||
first launch of the container. If */pgadmin4/config_distro.py* contains one or
|
||||
more lines, then no changes are made; for example, if the container instance is
|
||||
restarted, or */pgadmin4/config_distro.py* is mapped to a file on persistent
|
||||
storage (not recommended - use */pgadmin4/config_local.py* instead)!
|
||||
Quoting added by the shell or by YAML sits outside that Python expression and
|
||||
is removed before the value is written. The *docker run* example below keeps
|
||||
the Python quotes inside shell quotes, for example
|
||||
``-e 'PGADMIN_CONFIG_LOGIN_BANNER="Authorised users only!"'``.
|
||||
|
||||
In Docker Compose map syntax, YAML consumes the quotes around a scalar. A
|
||||
YAML-only quoted hostname, ``PGADMIN_CONFIG_MAIL_SERVER: "mail.example.com"``
|
||||
or ``PGADMIN_CONFIG_MAIL_SERVER: 'mail.example.com'``, reaches the container
|
||||
as ``mail.example.com`` and cannot serve as a Python string. Nest the Python
|
||||
quotes inside the scalar:
|
||||
``PGADMIN_CONFIG_MAIL_SERVER: "'mail.example.com'"``. The container then
|
||||
writes ``MAIL_SERVER = 'mail.example.com'``. ``"587"`` and ``"True"`` are
|
||||
already valid Python, so they need no nested quotes. The list form keeps
|
||||
single quotes, as ``docker run -e`` does
|
||||
(``- PGADMIN_CONFIG_MAIL_SERVER='mail.example.com'``); nesting them there
|
||||
produces ``"'mail.example.com'"``, which is valid Python but puts stray
|
||||
quote characters into the value and fails silently rather than at startup.
|
||||
An ``env_file`` value strips one layer of quotes, so the same nesting as the
|
||||
map form applies there. The Compose example in the Examples section uses the
|
||||
map form for an SMTP server, username, password, and *SECURITY_EMAIL_SENDER*.
|
||||
|
||||
Compose substitutes ``$VAR`` and ``${VAR}`` in Compose file values, and in
|
||||
double-quoted ``env_file`` values, so write a literal dollar as ``$$``.
|
||||
``"'pa$word'"`` reaches the container as ``'pa'``, with only a warning about
|
||||
an unset variable. The entrypoint does not expand the value again, so ``$$``
|
||||
is the only escaping required.
|
||||
|
||||
Settings are written to */pgadmin4/config_distro.py* within the container (or
|
||||
to the file named by *PGADMIN_CUSTOM_CONFIG_DISTRO_FILE*), which is read after
|
||||
*/pgadmin4/config.py* and before */pgadmin4/config_local.py*. Any settings
|
||||
given will therefore override anything in config.py, but can be overridden by
|
||||
settings in config_local.py.
|
||||
|
||||
The file is generated only when it is missing or empty, typically on first
|
||||
launch, and a non-empty file is left unchanged, including when the container
|
||||
is restarted. To apply corrected *PGADMIN_CONFIG_* values, recreate the
|
||||
container (``docker compose up -d`` does this when the environment changes,
|
||||
whereas ``docker compose restart`` does not), keeping the */var/lib/pgadmin*
|
||||
mapping to retain your data. If the file is on persistent storage, either
|
||||
because it is mapped (not recommended; use */pgadmin4/config_local.py*
|
||||
instead) or because *PGADMIN_CUSTOM_CONFIG_DISTRO_FILE* points there,
|
||||
recreating the container is not enough: edit the file, or empty it so that it
|
||||
is regenerated.
|
||||
|
||||
See :ref:`config_py` for more information on the available configuration settings.
|
||||
|
||||
@@ -220,13 +282,37 @@ host machine to allow configuration to be customised and shared between
|
||||
instances.
|
||||
|
||||
.. warning:: Warning: pgAdmin runs as the *pgadmin* user (UID: 5050) in the
|
||||
*pgadmin* group (GID: 5050) in the container. You must ensure that all files
|
||||
*root* group (GID: 0) in the container. You must ensure that all files
|
||||
are readable, and where necessary (e.g. the working/session directory)
|
||||
writeable for this user on the host machine. For example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo chown -R 5050:5050 <host_directory>
|
||||
sudo chown -R 5050:0 <host_directory>
|
||||
|
||||
When using rootless Podman, user IDs in the container are mapped to a
|
||||
range of subordinate IDs on the host, so changing the ownership of a host
|
||||
directory to 5050 as shown above does not make it writeable by the
|
||||
container. Instead, either add the *U* option to the volume, which has
|
||||
Podman recursively change the ownership of the directory and everything
|
||||
in it to match the container's user, for example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
-v /path/to/pgadmin-data:/var/lib/pgadmin:U
|
||||
|
||||
or start the container as root, which in a rootless container is your own
|
||||
user on the host. The container will change the ownership of
|
||||
*/var/lib/pgadmin* and then run pgAdmin as the *pgadmin* user; see *PUID*
|
||||
and *PGID* above. For example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
--user root
|
||||
|
||||
Neither option changes SELinux labels, so on a host with SELinux enforcing,
|
||||
the volume also needs the *z* (shared) or *Z* (private) option to relabel
|
||||
it, for example ``-v /path/to/pgadmin-data:/var/lib/pgadmin:Z,U``.
|
||||
|
||||
On some filesystems that do not support extended attributes, it may not be
|
||||
possible to run pgAdmin without specifying a value for *PGADMIN_LISTEN_PORT*
|
||||
@@ -304,6 +390,37 @@ Run a simple container over port 80, setting some configuration options:
|
||||
-e 'PGADMIN_CONFIG_CONSOLE_LOG_LEVEL=10' \
|
||||
-d dpage/pgadmin4
|
||||
|
||||
Run pgAdmin with Docker Compose, publishing port 80 and sending mail through
|
||||
an external SMTP server. *PGADMIN_DEFAULT_EMAIL* and
|
||||
*PGADMIN_DEFAULT_PASSWORD* are ordinary strings. Each *PGADMIN_CONFIG_*
|
||||
string below includes the Python quotes that YAML would otherwise remove.
|
||||
*PGADMIN_DISABLE_POSTFIX* may be any non-empty value; it is ordinary text,
|
||||
not a Python expression. The named volume keeps */var/lib/pgadmin* when the
|
||||
container is recreated:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
services:
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: user@domain.com
|
||||
PGADMIN_DEFAULT_PASSWORD: SuperSecret
|
||||
PGADMIN_DISABLE_POSTFIX: "1"
|
||||
PGADMIN_CONFIG_MAIL_SERVER: "'mail.example.com'"
|
||||
PGADMIN_CONFIG_MAIL_PORT: "587"
|
||||
PGADMIN_CONFIG_MAIL_USE_TLS: "True"
|
||||
PGADMIN_CONFIG_MAIL_USERNAME: "'smtp-user'"
|
||||
PGADMIN_CONFIG_MAIL_PASSWORD: "'s3cret=value'"
|
||||
PGADMIN_CONFIG_SECURITY_EMAIL_SENDER: "'pgadmin@example.com'"
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
|
||||
volumes:
|
||||
pgadmin-data:
|
||||
|
||||
Run a TLS secured container using a shared config/storage directory in
|
||||
/private/var/lib/pgadmin on the host, and servers pre-loaded from
|
||||
/tmp/servers.json on the host:
|
||||
|
||||
@@ -15,8 +15,15 @@ to specify the number of rows you would like to display in the editor panel.
|
||||
|
||||
To modify the content of a table, each row in the table must be uniquely
|
||||
identifiable. If the table definition does not include an OID or a primary key,
|
||||
the displayed data is read only. Note that views cannot be edited; updatable
|
||||
views (using rules) are not supported.
|
||||
the displayed data is read only. Simple, automatically-updatable views (a
|
||||
single base table, with no ``INSTEAD OF`` triggers or rewrite rules, whose
|
||||
base table's primary key columns are all exposed in the view under their
|
||||
original names) can also be edited; updating and deleting rows is supported,
|
||||
but inserting new rows through a view is not, so the *Add row* and *Paste*
|
||||
buttons are disabled when editing a view. Materialized views, views based
|
||||
on more than one table, views exposing only part of a composite primary key,
|
||||
and views relying on ``INSTEAD OF`` triggers or rules created with
|
||||
``CREATE RULE`` remain read only.
|
||||
|
||||
The editor features a toolbar that allows quick access to frequently used
|
||||
options, and a work environment divided into two panels:
|
||||
|
||||
@@ -39,6 +39,8 @@ Use the fields in the *Definition* tab to define the function:
|
||||
* Use the drop-down listbox next to *Return type* to select the data type
|
||||
returned by the function, if any. If *Custom return type?* is enabled this field
|
||||
will change to an input text field.
|
||||
* Move the *Returns a set?* switch to indicate if the function returns a set
|
||||
that includes multiple rows. The default is *No*.
|
||||
* Use the drop-down listbox next to *Language* to select the implementation
|
||||
language. The default is *sql*.
|
||||
* Use the drop-down listbox next to *Depends on extensions* to select the extension that this function
|
||||
@@ -88,8 +90,6 @@ function:
|
||||
* *IMMUTABLE* indicates that the function cannot modify the database and
|
||||
always returns the same result when given the same argument values.
|
||||
|
||||
* Move the *Returns a Set?* switch to indicate if the function returns a set
|
||||
that includes multiple rows. The default is *No*.
|
||||
* Move the *Strict?* switch to indicate if the function always returns NULL
|
||||
whenever any of its arguments are NULL. If *Yes*, the function is not executed
|
||||
when there are NULL arguments; instead a NULL result is assumed
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 51 KiB |
@@ -138,6 +138,15 @@ imported server.
|
||||
|
||||
Password fields cannot be imported or exported.
|
||||
|
||||
In desktop mode, ``PasswordExecCommand`` is the shell command to run, as shown
|
||||
in the example below. In server mode it must instead be the name of a command
|
||||
defined in the ``SERVER_PASSEXEC_COMMANDS`` setting (see :ref:`config_py`), and
|
||||
the same name is written when servers are exported. A server whose
|
||||
``PasswordExecCommand`` is not the name of a configured command is still
|
||||
imported, but without a password exec command, and a warning naming the server
|
||||
is printed. ``PasswordExecExpiration`` is imported only together with a valid
|
||||
command name.
|
||||
|
||||
The following example shows both a minimally defined and a fully defined server:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@@ -26,6 +26,7 @@ of database objects.
|
||||
developer_tools
|
||||
processes
|
||||
pgagent
|
||||
pgtimetable_chains
|
||||
contributions
|
||||
release_notes
|
||||
licence
|
||||
|
||||
@@ -34,7 +34,7 @@ from *config.py* file and modify the values for the following parameters.
|
||||
through kerberos. If that authentication fails, then it will return back
|
||||
to the login page where you need to provide internal pgAdmin user
|
||||
credentials for authentication."
|
||||
"KERBEROS_AUTO_CREATE_USER", "Set the value to *True* if you want to
|
||||
"KRB_AUTO_CREATE_USER", "Set the value to *True* if you want to
|
||||
automatically create a pgAdmin user corresponding to a successfully
|
||||
authenticated Kerberos user. Please note that password is not stored in the
|
||||
pgAdmin database."
|
||||
@@ -126,6 +126,82 @@ PostgreSQL Server settings to configure Kerberos Authentication
|
||||
* Note that, you have to login into pgAdmin with Kerberos authentication to
|
||||
then connect to PostgreSQL using Kerberos.
|
||||
|
||||
* In server mode, the only Kerberos credentials used to connect to a
|
||||
PostgreSQL server are those delegated by a user who logged in to pgAdmin
|
||||
with Kerberos, and only for a server with "Kerberos authentication"
|
||||
enabled. The default credential cache and client keytab of the operating
|
||||
system account that pgAdmin runs as are never used, so a user cannot
|
||||
connect to a server with that account's Kerberos identity.
|
||||
|
||||
* To enforce this for Windows SSPI, which uses the logon of the account
|
||||
pgAdmin runs as, and for gssproxy, which environment variables do not
|
||||
stop, in server mode pgAdmin also tells libpq what to use and what to
|
||||
refuse.
|
||||
|
||||
* For a Kerberos-login user's connection to a server with Kerberos
|
||||
authentication enabled, pgAdmin forces ``gsslib=gssapi``, replacing any
|
||||
``gsslib`` the user set. Without it, a libpq built with both GSSAPI and
|
||||
SSPI answers the server's request with SSPI, which uses the Windows
|
||||
logon of the account pgAdmin runs as and ignores the delegated
|
||||
credentials, whereas ``gsslib=gssapi`` makes libpq use the delegated
|
||||
credential cache. This helps only with a Windows libpq build that
|
||||
includes both GSSAPI and SSPI, as pgAdmin's Windows packages do. With
|
||||
libpq 16 or later, SSPI is also refused with ``require_auth=!sspi``;
|
||||
with an older libpq it is not refused.
|
||||
|
||||
* Every other connection uses ``require_auth=!gss,!sspi`` and
|
||||
``gssencmode=disable``, merged with the server's own "Require
|
||||
authentication" (``require_auth``) parameter. A server whose
|
||||
``require_auth`` allows only ``gss`` can be used only by a
|
||||
Kerberos-login user connecting to a server with Kerberos authentication
|
||||
enabled, and one that allows only ``sspi`` cannot be used in server
|
||||
mode.
|
||||
|
||||
* The ``require_auth`` protection needs libpq 16 or later in pgAdmin.
|
||||
Without it only the ``KRB5CCNAME`` and ``KRB5_CLIENT_KTNAME`` isolation
|
||||
applies to connections that may not use Kerberos, which does not cover
|
||||
Windows SSPI or gssproxy. ``gssencmode`` needs libpq 12 or later;
|
||||
``gsslib`` is known to every libpq that pgAdmin supports.
|
||||
|
||||
* psql and the utilities (such as pg_dump) get no ``require_auth`` in their
|
||||
connection string unless the user set one for the server, which is then
|
||||
merged as above. Their protection is the ``PGREQUIREAUTH``,
|
||||
``PGGSSENCMODE`` and ``PGGSSLIB`` environment variables. Only a libpq
|
||||
16 or later in the utility honours ``PGREQUIREAUTH``; an older one
|
||||
ignores it silently.
|
||||
|
||||
* A service file entry's settings take precedence over these environment
|
||||
variables for psql and the utilities, whereas pgAdmin's own connections
|
||||
are not affected, because their connection string takes precedence over
|
||||
the service file. Only a service listed in
|
||||
``SERVER_MODE_ALLOWED_SERVICES`` can be used in server mode, so only an
|
||||
entry the administrator has written can do this. Do not set
|
||||
``require_auth``, ``gssencmode`` or ``gsslib`` in such an entry unless
|
||||
every pgAdmin user may use what it allows: the ``KRB5CCNAME`` and
|
||||
``KRB5_CLIENT_KTNAME`` isolation cannot be overridden by a service file,
|
||||
but ``require_auth`` and ``gsslib`` in a listed service can re-enable
|
||||
Windows SSPI or gssproxy for the utilities.
|
||||
|
||||
|
||||
Accounts
|
||||
========
|
||||
|
||||
A Kerberos user is only ever logged in to the Kerberos account whose username
|
||||
is their principal (for example *user@EXAMPLE.COM*), never to an ``INTERNAL``
|
||||
or other account with the same username. If there is no such Kerberos account
|
||||
and *KRB_AUTO_CREATE_USER* is *False*, the login fails.
|
||||
|
||||
The administrator account created when pgAdmin is first set up is an
|
||||
``INTERNAL`` account. If you use Kerberos authentication only, create a
|
||||
Kerberos administrator account and deactivate the initial one:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user user@EXAMPLE.COM --auth-source kerberos --admin
|
||||
/path/to/python /path/to/setup.py update-user admin@example.com --inactive
|
||||
|
||||
See :ref:`Accounts from different authentication sources
|
||||
<auth_source_accounts>` for more information.
|
||||
|
||||
Master Password
|
||||
===============
|
||||
|
||||
@@ -15,47 +15,47 @@ When using main browser window, the following keyboard shortcuts are available:
|
||||
:class: longtable
|
||||
:widths: 2 2 3
|
||||
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
|
||||
+============================+====================+====================================+
|
||||
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + b | Shift + Option + b | Object explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + n | Shift + Option + n | Create object |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + d | Shift + Option + d | Delete object |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + g | Shift + Option + g | Direct debugging |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + e | Shift + Option + e | Edit object properties |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + f | Shift + Option + f | File main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + h | Shift + Option + h | Help main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + o | Shift + Option + o | Object main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + q | Shift + Option + q | Open query tool |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| F5 | F5 | Refresh object explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + s | Shift + Option + s | Search objects |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + [ | Shift + Option + [ | Tabbed panel backward |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + ] | Shift + Option + ] | Tabbed panel forward |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + l | Shift + Option + l | Tools main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + v | Shift + Option + v | View data |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
|
||||
+============================+===========================+====================================+
|
||||
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + b | Shift + Option + b | Object explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + n | Shift + Option + n | Create object |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + d | Shift + Option + d | Delete object |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + g | Shift + Option + g | Direct debugging |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + e | Shift + Option + e | Edit object properties |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + f | Shift + Option + f | File main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + h | Shift + Option + h | Help main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + o | Shift + Option + o | Object main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + q | Shift + Option + q | Open query tool |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| F5 | F5 | Refresh object explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + s | Shift + Option + s | Search objects |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + Page Up | Ctrl + Option + Page Up | Tabbed panel backward |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + Page Down | Ctrl + Option + Page Down | Tabbed panel forward |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + l | Shift + Option + l | Tools main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + v | Shift + Option + v | View data |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
|
||||
Property Grid Controls
|
||||
**********************
|
||||
@@ -100,6 +100,8 @@ When using the syntax-highlighting SQL editors, the following shortcuts are avai
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + / | Cmd + / | Comment/Uncomment code (Block) |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + Shift + d | Cmd + Shift + d | Duplicate current line/selection |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + a | Cmd + a | Select all |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + c | Cmd + c | Copy selected text to the clipboard |
|
||||
|
||||
@@ -133,5 +133,51 @@ credentials is the responsibility of:
|
||||
of pgAdmin and protects against attackers who would otherwise reach the
|
||||
LDAP server through pgAdmin.
|
||||
|
||||
See :ref:`Avoiding a bruteforce attack <login>` for the equivalent note
|
||||
on the login page.
|
||||
See :ref:`Avoiding a bruteforce attack <avoiding_bruteforce>` for the equivalent note
|
||||
on the login page.
|
||||
|
||||
Internal accounts
|
||||
*****************
|
||||
|
||||
pgAdmin treats accounts from different authentication sources as separate
|
||||
accounts, even when they share a username. An LDAP user is only ever logged
|
||||
in to the LDAP account with that username, and never to an ``INTERNAL``
|
||||
account with the same name, so each account keeps its own role and
|
||||
settings.
|
||||
|
||||
The administrator account created when pgAdmin is first set up always uses
|
||||
the ``INTERNAL`` authentication source. It remains in the pgAdmin database
|
||||
when *AUTHENTICATION_SOURCES* is set to *['ldap']*, although it can then no
|
||||
longer be used to log in. If you use LDAP authentication only, create an
|
||||
LDAP administrator account, for example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser --auth-source ldap --admin
|
||||
|
||||
and then deactivate the initial ``INTERNAL`` administrator account so that
|
||||
it cannot be used if internal authentication is enabled again later:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py update-user admin@example.com --inactive
|
||||
|
||||
When both *internal* and *ldap* are listed in *AUTHENTICATION_SOURCES*, a
|
||||
username and password entered on the login page are tried against each source
|
||||
in turn, in the order they are listed, and the first source that accepts the
|
||||
password signs the user in; the sources listed after it are not tried. Every
|
||||
source listed before it is still tried, so the password is a failed attempt at
|
||||
each of those. If the same username exists as both an internal and an LDAP
|
||||
account, a wrong password sent to the directory is a failed LDAP bind that
|
||||
counts towards the directory's lockout, even when a source listed later (such
|
||||
as the internal account) then accepts the sign-in. pgAdmin's own lockout
|
||||
counter for the internal account increases only when the sign-in fails at every
|
||||
source. Avoid giving an internal and an LDAP account the same username.
|
||||
|
||||
When *LDAP_DN_CASE_SENSITIVE* is off, names that differ only in case are the
|
||||
same login. Do not create two accounts whose usernames differ only in case;
|
||||
pgAdmin cannot tell which one a sign-in is for.
|
||||
|
||||
See :ref:`Accounts from different authentication sources
|
||||
<auth_source_accounts>` and :ref:`User Management <user_management>` for more
|
||||
information on the ``setup.py`` user management commands.
|
||||
@@ -52,10 +52,17 @@ to launch a password recovery utility.
|
||||
If you have forgotten the email associated with your account, please contact
|
||||
your administrator.
|
||||
|
||||
Please note that your LDAP password cannot be recovered using this page. If
|
||||
you enter your LDAP username in the *Email Address/Username* field, and then
|
||||
enter your email to recover your password, an error message will be displayed
|
||||
asking you to contact the LDAP administrator to recover your LDAP password.
|
||||
Password recovery works only for accounts using the ``INTERNAL``
|
||||
authentication source, which are the only accounts whose password pgAdmin
|
||||
holds. Recovery looks up the email address you enter and acts on the ``INTERNAL``
|
||||
account that has it: if one exists, reset instructions are sent for that
|
||||
account. The password of an account authenticated against an external source
|
||||
(``LDAP``, ``OAUTH2``, ``KERBEROS`` or ``WEBSERVER``) cannot be recovered here,
|
||||
because it is held by that source; if the email belongs only to such an
|
||||
account, an error asks you to contact that source's administrator. See
|
||||
:ref:`Accounts from different authentication sources <auth_source_accounts>`.
|
||||
|
||||
.. _avoiding_bruteforce:
|
||||
|
||||
Avoiding a bruteforce attack
|
||||
****************************
|
||||
|
||||
@@ -39,6 +39,7 @@ node, and select *Create Cast...*
|
||||
publication_dialog
|
||||
schema_dialog
|
||||
sequence_dialog
|
||||
statistics_dialog
|
||||
subscription_dialog
|
||||
synonym_dialog
|
||||
trigger_function_dialog
|
||||
|
||||
@@ -72,7 +72,7 @@ following options (in alphabetical order):
|
||||
| | |
|
||||
| 2) *Deploy Cloud Instance*| Click to open the :ref:`Cloud Deployment <cloud_deployment>` dialog to deploy an cloud instance. |
|
||||
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
|
||||
| *Change Password...* | Click to open the :ref:`Change Password... <change_password_dialog>` dialog to change your password. |
|
||||
| *Change Server Password...* | Click to open the :ref:`Change Server Password... <change_password_dialog>` dialog to change the server password. |
|
||||
+-----------------------------+--------------------------------------------------------------------------------------------------------------------------+
|
||||
| *Clear Saved Password* | If you have saved the database server password, click to clear the saved password. |
|
||||
| | Enable only when password is already saved. |
|
||||
|
||||
@@ -30,6 +30,9 @@ modify the values for the following parameters.
|
||||
"MFA_SUPPORTED_METHODS", "Set the authentication methods to be supported e.g. email, authenticator"
|
||||
"MFA_EMAIL_SUBJECT", "<APP_NAME> - Verification Code e.g. pgAdmin 4 -
|
||||
Verification Code"
|
||||
"MFA_EMAIL_CODE_MAX_ATTEMPTS", "The number of wrong entries allowed for one
|
||||
emailed verification code, after which the code is discarded and a new one
|
||||
must be sent. The default value is 5. Values below 1 are treated as 1."
|
||||
"MFA_FORCE_REGISTRATION", "Force the user to configure the authentication
|
||||
method on login (if no authentication is already configured)."
|
||||
|
||||
|
||||
@@ -88,11 +88,46 @@ and secure.
|
||||
"OAUTH2_AUTO_CREATE_USER", "Set the value to *True* if you want to automatically
|
||||
create a pgAdmin user corresponding to a successfully authenticated Oauth2 user.
|
||||
Please note that password is not stored in the pgAdmin database."
|
||||
"OAUTH2_AUTO_LOGIN", "Set the value to *True* to send users straight to the OAuth2 provider
|
||||
instead of showing the pgAdmin login page. This only takes effect when *oauth2* is the only
|
||||
value in AUTHENTICATION_SOURCES and exactly one provider is configured. The default is *False*.
|
||||
See :ref:`oauth2_auto_login` below."
|
||||
"OAUTH2_ADDITIONAL_CLAIMS", "If a dictionary is provided, pgAdmin will check for a matching key and value on the **ID token first** (for OIDC providers), then fall back to the userinfo endpoint response. In case there is no match with the provided config, the user will receive an authorization error. Useful for checking AzureAD_ *wids* or *groups*, GitLab_ *owner*, *maintainer* and *reporter* claims."
|
||||
"OAUTH2_SERVER_GROUP_CLAIM", "The name of a claim listing the server groups whose shared servers the user may access. If set, shared servers owned by other users are only visible and accessible if the name of their server group is in the claim. See :ref:`oauth2_server_group_claims` below."
|
||||
"OAUTH2_SERVER_GROUP_CLAIM_MAPPING", "An optional dictionary mapping values of the OAUTH2_SERVER_GROUP_CLAIM claim to a server group name or a list of server group names. Values without a mapping are used as server group names unchanged."
|
||||
"OAUTH2_SSL_CERT_VERIFICATION", "Set this variable to False to disable SSL certificate verification for OAuth2 provider.
|
||||
This may need to set False, in case of self-signed certificates."
|
||||
"OAUTH2_CHALLENGE_METHOD", "Enable PKCE workflow. PKCE method name, only *S256* is supported"
|
||||
"OAUTH2_RESPONSE_TYPE", "Enable PKCE workflow. Mandatory with OAUTH2_CHALLENGE_METHOD, must be set to *code*"
|
||||
"OAUTH2_LOGOUT_URL", "The provider's logout endpoint, used to end the user's session with the provider
|
||||
when they log out of pgAdmin. *{redirect_uri}* and *{id_token}* in the value are replaced with the pgAdmin
|
||||
URL and the user's ID token. Ex: *https://example.com/realms/master/protocol/openid-connect/logout?post_logout_redirect_uri={redirect_uri}&id_token_hint={id_token}* (for Keycloak)"
|
||||
|
||||
Accounts
|
||||
========
|
||||
|
||||
An OAuth2 user is only ever logged in to the OAuth2 account whose username is
|
||||
the one resolved from their identity. When *OAUTH2_USERNAME_CLAIM* is set, that
|
||||
claim is used, taken from the ID token if present and otherwise from the
|
||||
userinfo profile. When it is not set, an OIDC provider uses the first of
|
||||
``email``, ``preferred_username`` or ``sub`` that is present, and a non-OIDC
|
||||
provider uses the email address. The user is never logged in to an ``INTERNAL``
|
||||
or other account with the same username. If there is no such OAuth2 account and
|
||||
*OAUTH2_AUTO_CREATE_USER* is *False*, the login fails. When you provision an
|
||||
administrator account for an OAuth2-only deployment, give it the username your
|
||||
provider resolves to, which may not be the email address.
|
||||
|
||||
The administrator account created when pgAdmin is first set up is an
|
||||
``INTERNAL`` account. If you use OAuth2 authentication only, create an OAuth2
|
||||
administrator account and deactivate the initial one:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user user@example.com --auth-source oauth2 --admin
|
||||
/path/to/python /path/to/setup.py update-user admin@example.com --inactive
|
||||
|
||||
See :ref:`Accounts from different authentication sources
|
||||
<auth_source_accounts>` for more information.
|
||||
|
||||
Redirect URL
|
||||
============
|
||||
@@ -119,6 +154,21 @@ After configuration, on restart, you can see the login page with the Oauth2 logi
|
||||
:alt: Oauth2 login
|
||||
:align: center
|
||||
|
||||
.. _oauth2_auto_login:
|
||||
|
||||
Skipping the Login Page
|
||||
-----------------------
|
||||
|
||||
If OAuth2 is the only authentication source and a single provider is configured, set
|
||||
OAUTH2_AUTO_LOGIN to *True* to send users straight to the provider rather than showing them the
|
||||
login page with a single button. Note that any LOGIN_BANNER will not be seen in this case.
|
||||
|
||||
The login page is still shown after a login fails, so that the error can be read, and after the
|
||||
user logs out, so that they are not logged straight back in by the provider. Setting
|
||||
OAUTH2_LOGOUT_URL for the provider is recommended, so that logging out of pgAdmin also ends the
|
||||
user's session with the provider. To show the login page at any other time, add *?no_auto_login*
|
||||
to its URL, for example *https://pgadmin.example.com/login?no_auto_login*.
|
||||
|
||||
PKCE Workflow
|
||||
=============
|
||||
|
||||
@@ -342,6 +392,49 @@ Example:
|
||||
# then userinfo endpoint if not found
|
||||
}]
|
||||
|
||||
.. _oauth2_server_group_claims:
|
||||
|
||||
Restricting Shared Servers with a Claim
|
||||
---------------------------------------
|
||||
|
||||
**OAUTH2_SERVER_GROUP_CLAIM** limits which shared servers an OAuth2 user can
|
||||
see, based on a claim from the identity provider such as a list of roles or
|
||||
groups. When it is set:
|
||||
|
||||
* A shared server owned by another user is only visible, and can only be
|
||||
connected to, if the name of the server group it is in appears in the claim.
|
||||
* If the claim is missing for a user, they see no shared servers owned by
|
||||
other users.
|
||||
* Servers and server groups that the user owns themselves are not affected.
|
||||
* Users who log in by other methods are not affected.
|
||||
|
||||
The claim is read from the ID token first, then from the userinfo endpoint
|
||||
response. Its value may be a string or a list of strings; values of any other
|
||||
type (for example, group objects returned by some providers) are ignored.
|
||||
Server group names are matched exactly, with no trimming or case folding.
|
||||
|
||||
**OAUTH2_SERVER_GROUP_CLAIM_MAPPING** can be used to translate claim values
|
||||
into server group names. A value may map to a single name or a list of names,
|
||||
and values with no mapping are used as server group names unchanged.
|
||||
|
||||
The claim is only read when the user logs in, so changes made at the identity
|
||||
provider take effect at the user's next login.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
OAUTH2_CONFIG = [{
|
||||
# ... other config ...
|
||||
'OAUTH2_SERVER_GROUP_CLAIM': 'roles',
|
||||
'OAUTH2_SERVER_GROUP_CLAIM_MAPPING': {
|
||||
'dba': ['Production', 'Staging'],
|
||||
'developer': 'Staging'
|
||||
},
|
||||
# A user with the roles ['developer', 'Reporting'] can see shared
|
||||
# servers in the 'Staging' and 'Reporting' server groups.
|
||||
}]
|
||||
|
||||
Legacy OAuth2 Configuration (Without OIDC)
|
||||
-------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
.. _pgtimetable_chains:
|
||||
|
||||
|
||||
***************************************
|
||||
`pgTimeTable Chains`:index:
|
||||
***************************************
|
||||
|
||||
**pg_timetable** is a PostgreSQL-based scheduling agent that runs and manages what are called `Chains`.
|
||||
Each chain is composed of a set of Tasks to be executed in the order they should run.
|
||||
A chain is assigned a single schedule using an extended cron-style format.
|
||||
This UI has been tested with pg_timetable 6.3.0 and above.
|
||||
|
||||
A task may be a series of *SQL* statements, a *BUILTIN* pg_timetable command (such as sending email)
|
||||
or an operating system *PROGRAM* program/batch/shell script.
|
||||
|
||||
Switches on the *pgTimeTable Chain* dialog (accessed
|
||||
through the *Properties* context menu) allow you to modify a chain or disable the whole chain,
|
||||
as well as modify tasks within the chain.
|
||||
|
||||
When you highlight the name of a defined chain in the pgAdmin tree control, the
|
||||
*Properties* tab of the main pgAdmin window will display details about the chain,
|
||||
and the *Statistics* tab will display details about the chain's execution.
|
||||
There are also statistics about each task.
|
||||
|
||||
pg_timetable supports the ability to have more than one job agent running at a time.
|
||||
Job agents can be deployed on various platforms and binaries are readily available to
|
||||
deploy on Linux, Windows, and Mac.
|
||||
|
||||
For complete installation instructions, configuration options, and command-line
|
||||
usage, please refer to the
|
||||
`pg_timetable documentation <https://cybertec-postgresql.github.io/pg_timetable/>`_.
|
||||
|
||||
If you are migrating from pgAgent or another PostgreSQL based scheduler, pg_timetable provides
|
||||
migration SQL queries to help in that migration. See the
|
||||
`migration tools <https://github.com/cybertec-postgresql/pg_timetable/tree/master/extras>`_
|
||||
on GitHub for details.
|
||||
|
||||
Viewing pgTimeTable
|
||||
********************
|
||||
To enable viewing of the *pgTimeTable Chains* node, enable it on the File -> Preferences -> Nodes.
|
||||
It will only show if the schema *timetable* and associated tables exist in your maintenance database.
|
||||
|
||||
Creating a Chain
|
||||
****************
|
||||
|
||||
Note that pg_timetable is not installed via `CREATE EXTENSION`.
|
||||
Instead you use a pgTimeTable agent to install the necessary tables.
|
||||
To create or manage a chain, use the pgAdmin tree control to browse to the
|
||||
server on which the timetable schema exists. The tree control will
|
||||
display a *pgTimeTable Chains* node, under which currently defined chains are
|
||||
displayed.
|
||||
|
||||
To add a new chain, right click on the *pgTimeTable Chains* node,
|
||||
and select *Create pgTimeTable Chain...* from the context menu.
|
||||
|
||||
When the chain dialog opens, use the tabs on the dialog to define the chain
|
||||
and its tasks.
|
||||
|
||||
.. image:: images/pgtimetable_chain_general.png
|
||||
:alt: pgTimeTable chain dialog general tab
|
||||
:align: center
|
||||
|
||||
Use the fields on the *General* tab to provide general information about the
|
||||
chain:
|
||||
|
||||
* Provide a name for the chain in the *Name* field.
|
||||
* Move the *Enabled* switch to the *Yes* position to enable the chain, or
|
||||
*No* to disable it.
|
||||
* Use the *Max instances* field to specify the number of parallel instances
|
||||
(clients) that may execute this chain simultaneously. Leave the field blank
|
||||
to allow unlimited parallel execution.
|
||||
* Use the *Timeout (ms)* field to specify the maximum number of milliseconds
|
||||
the chain may run before it is aborted. A value of *0* means no timeout.
|
||||
* Move the *Self-destruct* switch to *Yes* to have the chain delete itself
|
||||
after a successful run.
|
||||
* Move the *Exclusive execution* switch to *Yes* to pause all other parallel
|
||||
chains while this chain is executing.
|
||||
* Use the *Host agent* field to specify the client name of the agent running
|
||||
pgTimeTable that should execute this chain. Leave the field blank to allow
|
||||
any available host to run the chain.
|
||||
|
||||
.. note:: You can query the ``timetable.active_session`` view to see the
|
||||
client names reported by running pgTimeTable agents:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
SELECT DISTINCT client_name FROM timetable.active_session
|
||||
|
||||
Use the client name exactly as reported by the query in the *Host agent*
|
||||
field.
|
||||
|
||||
* Use the *On error* field to specify the behavior when an error occurs.
|
||||
Accepted values are:
|
||||
|
||||
* *NULL* - Use the default error handling.
|
||||
* *LOG* - Log the error and continue execution.
|
||||
* *ABORT* - Abort the chain on error.
|
||||
|
||||
* Use the *Schedule* field to specify a cron-style schedule for the chain.
|
||||
The format consists of a single field with 5 slots. As with regular cron,
|
||||
you can use a comma (`,`) for multiple values and an asterisk (`*`) as a wildcard for all.
|
||||
E.g. `20 1,5 * * *` would run at 1:20 and 5:20 AM.
|
||||
The timezone is always the timezone of the server the pgTimeTable database resides on.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
* * * * *
|
||||
| | | | |
|
||||
| | | | +---- day of the week (0 - 7) (0 and 7 = Sunday)
|
||||
| | | +------ month (1 - 12)
|
||||
| | +-------- day of the month (1 - 31)
|
||||
| +---------- hour (0 - 23)
|
||||
+------------ minute (0 - 59)
|
||||
|
||||
If you leave the field blank it runs every minute
|
||||
(the effective schedule is `* * * * *`).
|
||||
|
||||
In addition to cron syntax, pgTimeTable supports additional special case formats.
|
||||
Namely `@after`, `@every`, `@reboot`.
|
||||
|
||||
The `@every` and `@after` should be followed with additional arguments.
|
||||
For example `@every 2 hours`.
|
||||
|
||||
|
||||
.. image:: images/pgtimetable_chain_tasks.png
|
||||
:alt: pgTimeTable chain dialog tasks tab
|
||||
:align: center
|
||||
|
||||
Use the *Tasks* tab to define and manage the tasks that the chain will
|
||||
execute. Click the Add icon (+) to add a new task. Tasks are executed in
|
||||
order of their *Order* value.
|
||||
|
||||
Click the compose icon (located at the left side of the header) to open the
|
||||
task definition dialog. See `Chain Task Dialog`_ below for details on
|
||||
defining a task.
|
||||
|
||||
When you have finished defining the chain, you can use the *SQL* tab to review
|
||||
the SQL that will create or modify your chain.
|
||||
|
||||
Click the *Save* button to save the chain, or *Close* to exit without saving.
|
||||
Use the *Reset* button to remove your unsaved entries from the dialog.
|
||||
|
||||
After saving a chain, the chain will be listed under the *pgTimeTable Chains*
|
||||
node of the pgAdmin tree control. The *Properties* tab in the main pgAdmin
|
||||
window will display a high-level overview of the selected chain, and the
|
||||
*Statistics* tab will show the details of each execution run.
|
||||
|
||||
.. image:: images/pgtimetable_chain_properties.png
|
||||
:alt: pgTimeTable chain properties
|
||||
:align: center
|
||||
|
||||
To modify an existing chain or to review detailed information about a chain,
|
||||
right-click on a chain name, and select *Properties* from the context menu.
|
||||
|
||||
|
||||
Chain Task Dialog
|
||||
*****************
|
||||
|
||||
A chain task defines a single unit of work to be performed as part of a chain.
|
||||
Each chain can contain one or more tasks, which are executed sequentially in
|
||||
order.
|
||||
|
||||
.. image:: images/pgtimetable_task_general.png
|
||||
:alt: pgTimeTable task dialog general tab
|
||||
:align: center
|
||||
|
||||
Use the fields on the *General* tab to define the task:
|
||||
|
||||
* Provide a name for the task in the *Name* field.
|
||||
* Use the *Order* field to specify the execution order of the task within the
|
||||
chain. Tasks are executed in ascending order by this value.
|
||||
* Use the *Kind* drop-down to select the type of task:
|
||||
|
||||
* *SQL* - The task executes SQL statements.
|
||||
* *PROGRAM* - The task executes an operating system command.
|
||||
* *BUILTIN* - The task executes one of the built-in pgTimeTable commands.
|
||||
|
||||
* When *Kind* is set to *SQL*, use the *Connection string* field to optionally
|
||||
specify a libpq-style connection string for a remote database. Leave the
|
||||
field blank to execute against the pgTimeTable database.
|
||||
|
||||
.. note:: The *Connection string* field is only available when the
|
||||
pgTimeTable extension version supports it (version 5.x or later).
|
||||
It is ignored for *PROGRAM* and *BUILTIN* task kinds.
|
||||
|
||||
* Move the *Ignore error* switch to *Yes* to continue chain execution even if
|
||||
this task fails. Set to *No* to stop the chain when an error occurs.
|
||||
|
||||
.. image:: images/pgtimetable_task_code.png
|
||||
:alt: pgTimeTable task dialog code tab
|
||||
:align: center
|
||||
|
||||
Use the *Code* tab to provide the code or command for the task. The content
|
||||
of the *Code* tab depends on the selected *Kind*:
|
||||
|
||||
* If *Kind* is *SQL*, provide one or more SQL statements in the *SQL* field.
|
||||
Use ``$1``, ``$2``, etc. as parameter placeholders that will be substituted
|
||||
from the *Parameters* collection.
|
||||
* If *Kind* is *PROGRAM*, provide the full path and arguments of the program
|
||||
to execute in the *Program* field.
|
||||
* If *Kind* is *BUILTIN*, select one of the following internal commands from
|
||||
the *Internal Command* drop-down:
|
||||
|
||||
* *NoOp* - No operation; the task completes immediately.
|
||||
* *Sleep* - Pause execution for a duration specified by a parameter.
|
||||
* *Log* - Write a message to the pgTimeTable log.
|
||||
* *SendMail* - Send an email (requires parameters for recipient, subject,
|
||||
and body).
|
||||
* *Download* - Download a file from a URL.
|
||||
* *CopyFromFile* - Copy data from a file into a database table.
|
||||
* *CopyToFile* - Copy data from a database table to a file.
|
||||
* *Shutdown* - Shut down the pgTimeTable service.
|
||||
|
||||
.. image:: images/pgtimetable_task_parameters.png
|
||||
:alt: pgTimeTable task dialog parameters
|
||||
:align: center
|
||||
|
||||
Use the *Parameters* sub-collection to define parameter values that will be
|
||||
substituted into the task command. Click the Add icon (+) to add a new
|
||||
parameter row:
|
||||
|
||||
* Use the *Order* column to specify the parameter position (``$1``, ``$2``,
|
||||
etc.).
|
||||
* Use the *Value* column to provide the parameter value. The value may be a
|
||||
plain string or a JSON value.
|
||||
|
||||
Click the compose icon to close the task definition dialog, then click *Save*
|
||||
on the chain dialog to save all changes.
|
||||
|
||||
Refer to `pg_timetable samples <https://cybertec-postgresql.github.io/pg_timetable/latest/samples/>`_
|
||||
for the kind of parameters expected for each type.
|
||||
|
||||
|
||||
Both the Chain and Tasks have a statistics tab, which show the results of past runs of tasks.
|
||||
|
||||
|
||||
.. image:: images/pgtimetable_task_statistics.png
|
||||
:alt: pgTimeTable task statistics
|
||||
:align: center
|
||||
@@ -589,6 +589,15 @@ Use the fields on the *CSV/TXT Output* panel to control the CSV/TXT output.
|
||||
quoted in the CSV/TXT output; select *Strings*, *All*, or *None*.
|
||||
* Use the *Replace null values with* option to replace null values with
|
||||
specified string in the output file. Default is set to 'NULL'.
|
||||
* Use the *Output file encoding* drop-down listbox to specify the character
|
||||
encoding used when saving query results to a file. The default is utf-8; an
|
||||
encoding that is not listed can also be typed in. Any character that the
|
||||
chosen encoding cannot represent is written as a question mark (?).
|
||||
* Use the *Add byte order mark (BOM)?* switch to add a byte order mark at the
|
||||
start of the saved file when a UTF encoding is used. This helps applications
|
||||
such as Microsoft Excel detect the encoding correctly. This applies to the
|
||||
CSV/TXT output only. The utf-16, utf-32 and utf-8-sig encodings always
|
||||
include a BOM, whatever this is set to.
|
||||
|
||||
.. image:: images/preferences_sql_display.png
|
||||
:alt: Preferences sqleditor display options
|
||||
@@ -754,6 +763,9 @@ preferences for copied data.
|
||||
character for copied data.
|
||||
* Use the *Result copy quoting* drop-down listbox to select which type of fields
|
||||
require quoting; select *All*, *None*, or *Strings*.
|
||||
* When the *Copy with headers?* switch is set to true, the column headers are
|
||||
included by default when copying data from the results grid. This can still
|
||||
be toggled per-copy from the results grid copy options menu.
|
||||
* When the *Striped rows?* switch is set to true, the result grid will display
|
||||
rows with alternating background colors.
|
||||
|
||||
|
||||
@@ -210,10 +210,12 @@ Data Editing Options
|
||||
| *Save Data Changes* | Click the *Save Data Changes* icon to save data changes (insert, update, or delete) in the Data | F6 |
|
||||
| | Output Panel to the server. | |
|
||||
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
|
||||
| *Save results to* | Click the Save results to file icon to save the result set of the current query as a delimited | F8 |
|
||||
| *file* | text file (CSV, if the field separator is set to a comma). This button will only be enabled when | |
|
||||
| | a query has been executed and there are results in the data grid. You can specify the CSV/TXT | |
|
||||
| | settings in the Preference Dialogue under SQL Editor -> CSV/TXT output. | |
|
||||
| *Save results to* | Click the Save results to file icon to save the result set of the current query. By | F8 |
|
||||
| *file* | default it is saved as a delimited text file (CSV, if the field separator is set to a | |
|
||||
| | comma). Use the adjacent drop-down list to instead save the results as JSON or XML. | |
|
||||
| | This button is only enabled when a query has been executed and there are results in | |
|
||||
| | the data grid. You can specify the CSV/TXT settings (including the output file encoding | |
|
||||
| | and byte order mark) in the Preferences dialog under Query Tool -> CSV/TXT Output. | |
|
||||
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
|
||||
| Graph Visualiser | Use the Graph Visualiser button to generate graphs of the query results. | |
|
||||
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
|
||||
|
||||
@@ -12,6 +12,7 @@ notes for it.
|
||||
:maxdepth: 1
|
||||
|
||||
|
||||
release_notes_9_19
|
||||
release_notes_9_18
|
||||
release_notes_9_17
|
||||
release_notes_9_16
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
************
|
||||
Version 9.19
|
||||
************
|
||||
|
||||
Release date: 2026-10-29
|
||||
|
||||
This release contains a number of bug fixes and new features since the release of pgAdmin 4 v9.18.
|
||||
|
||||
.. warning:: In server mode, free-text Password Exec Commands are no longer
|
||||
run, and ``ENABLE_SERVER_PASS_EXEC_CMD`` is deprecated and ignored.
|
||||
Administrators now define the permitted commands by name in
|
||||
``SERVER_PASSEXEC_COMMANDS``, and users choose one of those names in the
|
||||
server properties. Configure ``SERVER_PASSEXEC_COMMANDS`` **before** the
|
||||
first start after upgrading: on that start, each existing command that
|
||||
exactly matches a configured entry is converted to its name, and every
|
||||
other command is cleared, which cannot be undone. If the setting is empty,
|
||||
the conversion is postponed and a warning is logged at each start. See
|
||||
``config.py`` and the server dialog documentation for details.
|
||||
|
||||
.. note:: Users who do not own a shared server never inherit the owner's
|
||||
password file; it is treated like the owner's password, SSH key and SSL
|
||||
client files. Each user can set their own password file on their copy of
|
||||
the server, and a file that everyone should use can be placed in a
|
||||
``SHARED_STORAGE`` directory and referenced as ``Name:/.pgpass``.
|
||||
|
||||
.. note:: In desktop mode, when an SSH tunnel is configured with an identity
|
||||
file or a password, pgAdmin no longer also offers keys from the SSH agent;
|
||||
the agent is still used when neither is configured. In server mode, SSH
|
||||
tunnels never use the SSH agent, or the keys and configuration in the
|
||||
``~/.ssh`` directory of the account pgAdmin runs as, so a tunnel that
|
||||
relied on them must be given an identity file or password on the server.
|
||||
|
||||
.. note:: On Python 3.10 and later, pgAdmin now requires Flask-Security-Too
|
||||
5.8.3 or later, which fixes an open redirect (GHSA-ccgp-pv8r-95wm). This
|
||||
matters to anyone installing pgAdmin from the Python wheel into an
|
||||
existing environment. Python 3.9 installations remain on the
|
||||
Flask-Security-Too 5.6 series.
|
||||
|
||||
Supported Database Servers
|
||||
**************************
|
||||
**PostgreSQL**: 14, 15, 16, 17 and 18
|
||||
|
||||
**EDB Advanced Server**: 14, 15, 16, 17 and 18
|
||||
|
||||
Bundled PostgreSQL Utilities
|
||||
****************************
|
||||
**psql**, **pg_dump**, **pg_dumpall**, **pg_restore**: 18.6
|
||||
|
||||
|
||||
New features
|
||||
************
|
||||
|
||||
| `Issue #2018 <https://github.com/pgadmin-org/pgadmin4/issues/2018>`_ - Add a Statistics node for creating and managing extended statistics.
|
||||
| `Issue #2363 <https://github.com/pgadmin-org/pgadmin4/issues/2363>`_ - Allow data to be edited through simple auto-updatable views in View/Edit Data.
|
||||
| `Issue #3205 <https://github.com/pgadmin-org/pgadmin4/issues/3205>`_ - Allow Query Tool results to be saved as JSON or XML, as well as CSV.
|
||||
| `Issue #3834 <https://github.com/pgadmin-org/pgadmin4/issues/3834>`_ - Add a keyboard shortcut to duplicate the current line or selection in the SQL editor.
|
||||
| `Issue #4128 <https://github.com/pgadmin-org/pgadmin4/issues/4128>`_ - Allow the encoding of Query Tool result files to be chosen.
|
||||
| `Issue #4129 <https://github.com/pgadmin-org/pgadmin4/issues/4129>`_ - Add a 'copy with headers' option for Query Tool results.
|
||||
| `Issue #5691 <https://github.com/pgadmin-org/pgadmin4/issues/5691>`_ - Close dialogs with the Escape key.
|
||||
| `Issue #6695 <https://github.com/pgadmin-org/pgadmin4/issues/6695>`_ - Allow a byte order mark to be added to Query Tool result files.
|
||||
| `Issue #7167 <https://github.com/pgadmin-org/pgadmin4/issues/7167>`_ - Allow dialogs such as Sort/Filter to be saved and closed with the Enter key.
|
||||
| `Issue #8647 <https://github.com/pgadmin-org/pgadmin4/issues/8647>`_ - Allow shift + click to extend the cell range selection in the Query Tool results grid.
|
||||
| `Issue #9057 <https://github.com/pgadmin-org/pgadmin4/issues/9057>`_ - Support UNLOGGED partitioned tables correctly, which PostgreSQL 18 no longer allows to be created or altered.
|
||||
| `Issue #9064 <https://github.com/pgadmin-org/pgadmin4/issues/9064>`_ - Add the PostgreSQL 18 pg_dump options to the Backup and Backup Server dialogs.
|
||||
| `Issue #9066 <https://github.com/pgadmin-org/pgadmin4/issues/9066>`_ - Add the PostgreSQL 18 pg_restore options to the Restore dialog.
|
||||
| `Issue #9212 <https://github.com/pgadmin-org/pgadmin4/issues/9212>`_ - Show fill factor, TOAST tuple target and parallel workers in the table Properties panel.
|
||||
| `Issue #9264 <https://github.com/pgadmin-org/pgadmin4/issues/9264>`_ - Move the 'Returns a set?' option to the Definition tab in the Function dialog.
|
||||
| `Issue #9704 <https://github.com/pgadmin-org/pgadmin4/issues/9704>`_ - Allow an OAuth2 claim to restrict which shared servers a user can see, configured through ``OAUTH2_SERVER_GROUP_CLAIM`` and ``OAUTH2_SERVER_GROUP_CLAIM_MAPPING``.
|
||||
| `Issue #9975 <https://github.com/pgadmin-org/pgadmin4/issues/9975>`_ - Allow the login page to be skipped when OAuth2 is the only authentication source, using ``OAUTH2_AUTO_LOGIN`` and ``OAUTH2_LOGOUT_URL``.
|
||||
| `Issue #10148 <https://github.com/pgadmin-org/pgadmin4/issues/10148>`_ - Add support for managing pg_timetable chains and tasks.
|
||||
| `Issue #10249 <https://github.com/pgadmin-org/pgadmin4/issues/10249>`_ - Replace free-text Password Exec Commands in server mode with an administrator-defined allowlist, ``SERVER_PASSEXEC_COMMANDS``. Commands run without a shell, with a reduced environment, and receive the pgAdmin user's identity in ``PGADMIN_PASSEXEC_*`` variables; users of a shared server inherit the owner's command by default.
|
||||
| `Issue #10473 <https://github.com/pgadmin-org/pgadmin4/issues/10473>`_ - Add a macOS 26 layered app icon with dark, clear and tinted variants.
|
||||
| `Issue #10499 <https://github.com/pgadmin-org/pgadmin4/issues/10499>`_ - Add pgEdge Starfleet to the Deploy Cloud Instance wizard, for creating Starfleet Managed databases.
|
||||
| `Issue #10503 <https://github.com/pgadmin-org/pgadmin4/issues/10503>`_ - Add numpad zoom shortcuts to the Desktop View menu.
|
||||
|
||||
Housekeeping
|
||||
************
|
||||
|
||||
| `Issue #5369 <https://github.com/pgadmin-org/pgadmin4/issues/5369>`_ - Clarify the configuration files documentation.
|
||||
| `Issue #9230 <https://github.com/pgadmin-org/pgadmin4/issues/9230>`_ - Rename the server 'Change Password' option to 'Change Server Password', and the user menu option to 'Change pgAdmin Password', so the two can no longer be confused.
|
||||
| `Issue #9331 <https://github.com/pgadmin-org/pgadmin4/issues/9331>`_ - Remove unused Google API documents, Babel locale data and packaging tools from the packages.
|
||||
| `Issue #9524 <https://github.com/pgadmin-org/pgadmin4/issues/9524>`_ - Document running the container with rootless Podman, and the ``PUID`` and ``PGID`` environment variables.
|
||||
| `Issue #10363 <https://github.com/pgadmin-org/pgadmin4/issues/10363>`_ - Enforce ``--immutable`` on every yarn install, in CI and the release builds, to harden the supply chain.
|
||||
| `Issue #10546 <https://github.com/pgadmin-org/pgadmin4/issues/10546>`_ - Fix the flaky ``test_windows_pty_failures`` test counting psql output from other tests.
|
||||
| `Issue #10552 <https://github.com/pgadmin-org/pgadmin4/issues/10552>`_ - Add a test for Reset discarding pending SQL editor values in dialogs.
|
||||
| `Issue #10553 <https://github.com/pgadmin-org/pgadmin4/issues/10553>`_ - Make the server tests restore the shared test fixture after themselves.
|
||||
| `Issue #10601 <https://github.com/pgadmin-org/pgadmin4/issues/10601>`_ - Remove the dead tree ``loadfail`` handler from ``browser.js``.
|
||||
| `Issue #10602 <https://github.com/pgadmin-org/pgadmin4/issues/10602>`_ - Fix the Jest ``addNewDatagridRow`` helper so schema UI specs actually add a row.
|
||||
|
||||
Bug fixes
|
||||
*********
|
||||
|
||||
| `Issue #5940 <https://github.com/pgadmin-org/pgadmin4/issues/5940>`_ - Fix View/Edit Data failing on SQL_ASCII databases when a name contains a backslash followed by 'u' or 'U'.
|
||||
| `Issue #6090 <https://github.com/pgadmin-org/pgadmin4/issues/6090>`_ - Refresh a stale server connection manager when a server id is reused, so one user's session can no longer pick up settings from another server.
|
||||
| `Issue #6231 <https://github.com/pgadmin-org/pgadmin4/issues/6231>`_ - Generate a single GRANT statement for roles with identical privileges.
|
||||
| `Issue #6481 <https://github.com/pgadmin-org/pgadmin4/issues/6481>`_ - Fix index and constraint columns not being shown when their names require quoting.
|
||||
| `Issue #6652 <https://github.com/pgadmin-org/pgadmin4/issues/6652>`_ - Fix Schema Diff treating functions that differ only in OUT arguments or return type as unrelated objects.
|
||||
| `Issue #6671 <https://github.com/pgadmin-org/pgadmin4/issues/6671>`_ - Allow the emailed MFA code to be re-entered after an invalid code is entered.
|
||||
| `Issue #7232 <https://github.com/pgadmin-org/pgadmin4/issues/7232>`_ - Fix the tab switching shortcuts not working whilst focus is inside the PSQL, Query, ERD or Schema Diff tools.
|
||||
| `Issue #7612 <https://github.com/pgadmin-org/pgadmin4/issues/7612>`_ - Fix the server context menu showing database options after the tree state is restored.
|
||||
| `Issue #7619 <https://github.com/pgadmin-org/pgadmin4/issues/7619>`_ - Fix Query Tool autocomplete running as the wrong user, or failing, after changing the connection to a different user.
|
||||
| `Issue #8015 <https://github.com/pgadmin-org/pgadmin4/issues/8015>`_ - Re-sort renamed server groups and other nodes in the Object Explorer instead of waiting for a reload.
|
||||
| `Issue #8144 <https://github.com/pgadmin-org/pgadmin4/issues/8144>`_ - Connect the server when changing the Query Tool connection after it was disconnected.
|
||||
| `Issue #8529 <https://github.com/pgadmin-org/pgadmin4/issues/8529>`_ - Rewrite the container entrypoint using bash features, and pass values to the embedded Python safely, so an email address containing an apostrophe works and a validation error stops the container rather than passing silently.
|
||||
| `Issue #8664 <https://github.com/pgadmin-org/pgadmin4/issues/8664>`_ - Fix debugger messages from successive steps running together on one line.
|
||||
| `Issue #8991 <https://github.com/pgadmin-org/pgadmin4/issues/8991>`_ - Fix BEGIN, COMMIT and ROLLBACK silently failing in the Query Tool under server cursor mode.
|
||||
| `Issue #9130 <https://github.com/pgadmin-org/pgadmin4/issues/9130>`_ - Select the adjacent tab when the active tab is closed, rather than the left-most.
|
||||
| `Issue #9450 <https://github.com/pgadmin-org/pgadmin4/issues/9450>`_ - Allow roles holding ADMIN OPTION to manage Group Role membership.
|
||||
| `Issue #9603 <https://github.com/pgadmin-org/pgadmin4/issues/9603>`_ - Restore the per-tab toolbar options when Query Tool tabs are restored on relaunch.
|
||||
| `Issue #9624 <https://github.com/pgadmin-org/pgadmin4/issues/9624>`_ - Fix a database's label staying at '[Disconnecting...]' when disconnecting it fails.
|
||||
| `Issue #9630 <https://github.com/pgadmin-org/pgadmin4/issues/9630>`_ - Fix the parameter value dropdown keeping stale options when the parameter name is changed.
|
||||
| `Issue #9672 <https://github.com/pgadmin-org/pgadmin4/issues/9672>`_ - Fix View/Edit Data not handling generated columns properly.
|
||||
| `Issue #9784 <https://github.com/pgadmin-org/pgadmin4/issues/9784>`_ - Fix modal dialogs losing keyboard focus to a tool iframe loading in the background.
|
||||
| `Issue #9798 <https://github.com/pgadmin-org/pgadmin4/issues/9798>`_ - Fix the collation and ctype query error for databases with a non-default LC_COLLATE.
|
||||
| `Issue #9814 <https://github.com/pgadmin-org/pgadmin4/issues/9814>`_ - Disable the SSH agent when an SSH tunnel identity file or password is provided.
|
||||
| `Issue #9818 <https://github.com/pgadmin-org/pgadmin4/issues/9818>`_ - Keep the AI Assistant's intermediate responses instead of replacing them with placeholder messages.
|
||||
| `Issue #9836 <https://github.com/pgadmin-org/pgadmin4/issues/9836>`_ - Fix the CREATE TABLE script ignoring the UNLOGGED attribute of a partition.
|
||||
| `Issue #9838 <https://github.com/pgadmin-org/pgadmin4/issues/9838>`_ - Fix formatting a view's SQL being treated as a column change that drops and re-creates the view.
|
||||
| `Issue #9865 <https://github.com/pgadmin-org/pgadmin4/issues/9865>`_ - Fix modal dialogs being hidden from assistive technology by ``aria-hidden`` on the application root.
|
||||
| `Issue #9890 <https://github.com/pgadmin-org/pgadmin4/issues/9890>`_ - Fix ERD tables becoming immovable after a Shift-drag selection.
|
||||
| `Issue #9921 <https://github.com/pgadmin-org/pgadmin4/issues/9921>`_ - Fix Azure and Google authentication in the cloud deployment wizard.
|
||||
| `Issue #9984 <https://github.com/pgadmin-org/pgadmin4/issues/9984>`_ - Report an unreachable external configuration database, or a malformed ``CONFIG_DATABASE_URI``, as a failed check rather than an unhandled exception.
|
||||
| `Issue #10031 <https://github.com/pgadmin-org/pgadmin4/issues/10031>`_ - Fix Query Tool tabs running their query automatically after a session restore.
|
||||
| `Issue #10103 <https://github.com/pgadmin-org/pgadmin4/issues/10103>`_ - Exclude non-editable alias and expression columns when saving edited rows in the Query Tool.
|
||||
| `Issue #10136 <https://github.com/pgadmin-org/pgadmin4/issues/10136>`_ - Restore tags for standard users on shared servers, and never inherit the owner's password file.
|
||||
| `Issue #10143 <https://github.com/pgadmin-org/pgadmin4/issues/10143>`_ - Fix switching back to a dialog tab containing a data grid being slow, by skipping virtualisation for small grids.
|
||||
| `Issue #10226 <https://github.com/pgadmin-org/pgadmin4/issues/10226>`_ - Restore remembering the last visited directory in the desktop file dialogs.
|
||||
| `Issue #10251 <https://github.com/pgadmin-org/pgadmin4/issues/10251>`_ - Fix invalid SQL from the Maintenance dialog for REINDEX ... CONCURRENTLY.
|
||||
| `Issue #10254 <https://github.com/pgadmin-org/pgadmin4/issues/10254>`_ - Fix the Object Explorer shortcut not moving keyboard focus into the tree.
|
||||
| `Issue #10273 <https://github.com/pgadmin-org/pgadmin4/issues/10273>`_ - Fix the dashboard transactions per second graph being wrong for refresh rates other than one second.
|
||||
| `Issue #10292 <https://github.com/pgadmin-org/pgadmin4/issues/10292>`_ - Fix Schema Diff scripts that convert a column to or from SERIAL.
|
||||
| `Issue #10301 <https://github.com/pgadmin-org/pgadmin4/issues/10301>`_ - Drop the scaffolding default partition after Schema Diff rebuilds a partitioned table.
|
||||
| `Issue #10310 <https://github.com/pgadmin-org/pgadmin4/issues/10310>`_ - Fix the Statistics node failing to list its children in Object Explorer.
|
||||
| `Issue #10334 <https://github.com/pgadmin-org/pgadmin4/issues/10334>`_ - Add Caddy reverse proxy configuration examples to the documentation.
|
||||
| `Issue #10347 <https://github.com/pgadmin-org/pgadmin4/issues/10347>`_ - Fix the selected UI language not persisting beyond the request that set it in server mode.
|
||||
| `Issue #10348 <https://github.com/pgadmin-org/pgadmin4/issues/10348>`_ - Fix OpenAI streaming tool calls being split into two entries in the AI Assistant.
|
||||
| `Issue #10349 <https://github.com/pgadmin-org/pgadmin4/issues/10349>`_ - Fix the OAuth2 userinfo request crashing when ``OAUTH2_USERINFO_ENDPOINT`` is set to None.
|
||||
| `Issue #10350 <https://github.com/pgadmin-org/pgadmin4/issues/10350>`_ - Fix the MAINTAIN privilege being silently dropped for views and materialised views on PostgreSQL 17 and later.
|
||||
| `Issue #10357 <https://github.com/pgadmin-org/pgadmin4/issues/10357>`_ - Fix the cursor jumping to the start of SQL fields in dialogs when keys are pressed together.
|
||||
| `Issue #10364 <https://github.com/pgadmin-org/pgadmin4/issues/10364>`_ - Explain Docker Compose quoting for ``PGADMIN_CONFIG_*`` values in the container documentation.
|
||||
| `Issue #10386 <https://github.com/pgadmin-org/pgadmin4/issues/10386>`_ - Stop the Query Tool spinner, and its idle CPU use, whilst a password prompt is open.
|
||||
| `Issue #10401 <https://github.com/pgadmin-org/pgadmin4/issues/10401>`_ - Show default privileges in the Database properties panel.
|
||||
| `Issue #10409 <https://github.com/pgadmin-org/pgadmin4/issues/10409>`_ - Include the authenticated user in the access log on logout.
|
||||
| `Issue #10414 <https://github.com/pgadmin-org/pgadmin4/issues/10414>`_ - Fix the Linux packages shipping a stray copy of Python as ``venv/bin/python``, which stopped the DEB package installing on Ubuntu 26.04.
|
||||
| `Issue #10415 <https://github.com/pgadmin-org/pgadmin4/issues/10415>`_ - Prefer the user's email address as the Gravatar identifier.
|
||||
| `Issue #10418 <https://github.com/pgadmin-org/pgadmin4/issues/10418>`_ - Remove the dead ``usr`` parameter from Query Tool connection URLs.
|
||||
| `Issue #10419 <https://github.com/pgadmin-org/pgadmin4/issues/10419>`_ - Fix Kerberos login failures returning a JSON 500 response, and first-time automatically created Kerberos logins being rejected.
|
||||
| `Issue #10422 <https://github.com/pgadmin-org/pgadmin4/issues/10422>`_ - Fix Schema Diff repeating unchanged column comments and identity options.
|
||||
| `Issue #10424 <https://github.com/pgadmin-org/pgadmin4/issues/10424>`_ - Fix Reset Layout dropping Query Tool tabs from the saved application state.
|
||||
| `Issue #10428 <https://github.com/pgadmin-org/pgadmin4/issues/10428>`_ - Fix connections crashing on Windows with an access violation, and the utilities failing to find ``gssapi64.dll``, by staging the Kerberos runtime next to libpq.
|
||||
| `Issue #10446 <https://github.com/pgadmin-org/pgadmin4/issues/10446>`_ - Fix Schema Diff reporting identical tables as different when a foreign key's referenced table has a dropped column.
|
||||
| `Issue #10449 <https://github.com/pgadmin-org/pgadmin4/issues/10449>`_ - Honour the password file in the Change Server Password dialog, rather than requiring the current password.
|
||||
| `Issue #10462 <https://github.com/pgadmin-org/pgadmin4/issues/10462>`_ - Fix duplicated content when opening large non-UTF-8 files in the Query Tool.
|
||||
| `Issue #10463 <https://github.com/pgadmin-org/pgadmin4/issues/10463>`_ - Pass the target database, and every supported connection parameter, to the utilities, so a database set in a service file no longer overrides the one selected.
|
||||
| `Issue #10470 <https://github.com/pgadmin-org/pgadmin4/issues/10470>`_ - Remove inherited columns from the Columns tab when their parent table is removed after reopening a table.
|
||||
| `Issue #10482 <https://github.com/pgadmin-org/pgadmin4/issues/10482>`_ - Allow saving files through symbolic links in desktop mode, and stop forcing 0600 permissions on them there.
|
||||
| `Issue #10485 <https://github.com/pgadmin-org/pgadmin4/issues/10485>`_ - Fix 'Cannot have two HTML5 backends at the same time' errors in dialogs containing a data grid.
|
||||
| `Issue #10511 <https://github.com/pgadmin-org/pgadmin4/issues/10511>`_ - Fix the Dashboard Logs tab failing on multi-byte characters split across read chunks.
|
||||
| `Issue #10527 <https://github.com/pgadmin-org/pgadmin4/issues/10527>`_ - Write the cloud deployment wizard's AWS, Azure and Google credentials through to the session store.
|
||||
| `Issue #10531 <https://github.com/pgadmin-org/pgadmin4/issues/10531>`_ - Fix the Query Tool saving files that were loaded as latin-1 back as UTF-8.
|
||||
| `Issue #10534 <https://github.com/pgadmin-org/pgadmin4/issues/10534>`_ - Fix the hardcoded table OID in the column properties query, which broke inherited column details.
|
||||
| `Issue #10547 <https://github.com/pgadmin-org/pgadmin4/issues/10547>`_ - Fix valid escapes below U+0100 being rewritten in SQL_ASCII data and names.
|
||||
| `Issue #10548 <https://github.com/pgadmin-org/pgadmin4/issues/10548>`_ - Fix negative identity column options being dropped from the generated SQL.
|
||||
| `Issue #10549 <https://github.com/pgadmin-org/pgadmin4/issues/10549>`_ - Fix Schema Diff not removing a column comment that exists only in the target.
|
||||
| `Issue #10550 <https://github.com/pgadmin-org/pgadmin4/issues/10550>`_ - Fix changing an identity column's START value also restarting its sequence.
|
||||
| `Issue #10551 <https://github.com/pgadmin-org/pgadmin4/issues/10551>`_ - Fix the cursor jumping to the start of SQL fields in dialogs after a quick edit and undo.
|
||||
| `Issue #10554 <https://github.com/pgadmin-org/pgadmin4/issues/10554>`_ - Fix column properties reporting an arbitrary parent for columns merged from several parents.
|
||||
| `Issue #10573 <https://github.com/pgadmin-org/pgadmin4/issues/10573>`_ - Fix Query Tool state save/restore failing on installations with a legacy ``SECRET_KEY``.
|
||||
| `Issue #10584 <https://github.com/pgadmin-org/pgadmin4/issues/10584>`_ - Preserve explicit 24-hour mode in date-time picker popups.
|
||||
| `Issue #10597 <https://github.com/pgadmin-org/pgadmin4/issues/10597>`_ - Fix Schema Diff treating a function whose input argument was renamed as two unrelated functions.
|
||||
| `Issue #10598 <https://github.com/pgadmin-org/pgadmin4/issues/10598>`_ - Keep the Query Tool execute options when its transaction is re-created.
|
||||
| `Issue #10600 <https://github.com/pgadmin-org/pgadmin4/issues/10600>`_ - Revert the Query Tool execute option checkboxes when changing an option fails.
|
||||
| `Issue #10603 <https://github.com/pgadmin-org/pgadmin4/issues/10603>`_ - Fix the Query Tool not re-running the query after reconnecting at a password prompt.
|
||||
| `Issue #10604 <https://github.com/pgadmin-org/pgadmin4/issues/10604>`_ - Report a failed autocomplete reconnect in the Query Tool instead of ignoring it.
|
||||
| `Issue #10607 <https://github.com/pgadmin-org/pgadmin4/issues/10607>`_ - Fix Query Tool autocomplete objects being shared between sessions that use the same transaction id.
|
||||
| `Issue #10612 <https://github.com/pgadmin-org/pgadmin4/issues/10612>`_ - Free Query Tool autocomplete objects when an idle session's connections are released.
|
||||
| `Issue #10613 <https://github.com/pgadmin-org/pgadmin4/issues/10613>`_ - Reconnect a lost Query Tool connection as the user and role it was changed to.
|
||||
| `Issue #10614 <https://github.com/pgadmin-org/pgadmin4/issues/10614>`_ - Keep external pages out of the desktop runtime's privileged windows and confine non-prompted downloads to the downloads directory.
|
||||
| `Issue #10618 <https://github.com/pgadmin-org/pgadmin4/issues/10618>`_ - Keep Query Tool execution disabled until a restored tab's execute options reach the server.
|
||||
| `Issue #10645 <https://github.com/pgadmin-org/pgadmin4/issues/10645>`_ - Fix connection redirection in the materialized view refresh, where the target database's name was passed straight to psql's ``--dbname``: because libpq expands a value containing an equals sign into a full connection string, a database named like ``host=... port=...``, which any role with only CREATEDB can create, redirected psql to a server of the creator's choosing and leaked the connecting user's saved password (CVE-2026-116252). The name is now passed through the same connection-information helper as the Backup, Restore, Maintenance and Import/Export tools, so libpq treats it strictly as a database name. Reported by Bae Seung-won (@baeseungwon1010).
|
||||
| `Issue #10646 <https://github.com/pgadmin-org/pgadmin4/issues/10646>`_ - Fix a user's other sessions surviving a password change, which left a session an attacker already held valid after the password was changed or reset (CVE-2026-116251). A self-service change now rotates the user's ``fs_uniquifier``, logging out every other session while the changing session stays signed in with its database connections, and saved passwords, including those of shared servers, are re-encrypted under the new key, with any that cannot be decrypted cleared, and all of them cleared if re-encryption fails. An administrator reset (including ``setup.py update-user``) also rotates ``fs_uniquifier``, ending all of that user's sessions, but does not re-encrypt their saved passwords. Reported by Zohaib.
|
||||
| `Issue #10647 <https://github.com/pgadmin-org/pgadmin4/issues/10647>`_ - Fix time-of-check to time-of-use symlink races in Import/Export Servers and the File Manager's upload, save and file-open paths, where a path validated against the user's storage directory was then opened by name, so a symbolic link swapped in at the file or any parent directory in between let an export overwrite, or an import read, a file outside the storage directory (CVE-2026-116250). In server mode on POSIX the file is now opened by walking down from the storage directory one component at a time with ``O_NOFOLLOW``; new files are created with mode 0600 and only regular files are read, opened non-blocking so a FIFO cannot hang the request. Symbolic links inside a storage directory are now refused, even when they point elsewhere inside it. Windows and desktop mode are unchanged, so the race remains there. Reported by MUTED CYBERSECURITY S.L.
|
||||
| `Issue #10648 <https://github.com/pgadmin-org/pgadmin4/issues/10648>`_ - Fix the same symlink races in the files handed to Backup, Restore and Import/Export Data, where the validated path was passed to pg_dump, pg_dumpall, pg_restore or psql, which opened it by name later, extending the fix for the vulnerability tracked in Issue #10647. In server mode on POSIX pgAdmin now opens the file itself with the descriptor-relative ``O_NOFOLLOW`` walk, accepts only a regular file and hands the utility ``/dev/fd/N``, and new backup and export files are created with mode 0600. This applies on Linux only, and not to directory-format Backup and Restore, where the utility opens the files inside the directory by name; on macOS and the BSDs the file is checked safely but the utility still reopens it by name.
|
||||
| `Issue #10649 <https://github.com/pgadmin-org/pgadmin4/issues/10649>`_ - Refuse hard links and FIFOs in the symlink-race fixes of Issue #10647 and Issue #10648, since ``O_NOFOLLOW`` stops only symbolic links: in server mode on POSIX, a file with more than one link is refused, for reading as well as before it is truncated for writing, and a FIFO can no longer hang an upload, save or export; Windows is not covered. The Import/Export Servers, save, Backup, Restore and Import/Export Data handlers also return the reason for a failure instead of text containing the absolute storage path.
|
||||
| `Issue #10650 <https://github.com/pgadmin-org/pgadmin4/issues/10650>`_ - Fix connection file paths escaping the user's storage directory, where a password file, SSL certificate, key or CRL, or SSH tunnel identity file such as ``../<another user>/.pgpass`` was joined to the storage directory after stripping only leading slashes, so a user could connect with another user's credentials or key, and where ``sslkeylogfile`` was copied into the connection string unchanged, letting libpq create or append a file as the pgAdmin service account (CVE-2026-116249). In server mode the resolved path, with symbolic links followed, must now stay inside the user's storage directory or the named shared storage directory, and ``sslkeylogfile`` is confined the same way. Connection parameter keys are also validated against the libpq keyword list, since a crafted key could smuggle its own ``keyword=value`` pairs past these checks. Servers whose file paths reach outside the storage directory, through ``..`` or a symbolic link, stop connecting until the files are moved into it or into a ``SHARED_STORAGE`` directory.
|
||||
| `Issue #10651 <https://github.com/pgadmin-org/pgadmin4/issues/10651>`_ - Fix any user able to register a server being able to share it with every other user in server mode, because the create and update endpoints stored whatever ``shared`` value they were sent, and the servers.json import removed a non-administrator's shared entries only after validation, so an early error left them in place (CVE-2026-116248). Such requests are now refused with a 403, and the import prunes and skips a non-administrator's shared entries before validating. The restriction is not retroactive: servers shared by non-administrators before the upgrade stay shared until the owner un-shares them or an administrator clears the ``shared`` column in the configuration database, and pgAdmin only logs a warning about each of them at startup, so administrators should review those servers and un-share any that should not be shared. ``Shared`` in a servers.json import must now be the JSON value ``true``, and a file that fails validation is refused as a whole.
|
||||
| `Issue #10652 <https://github.com/pgadmin-org/pgadmin4/issues/10652>`_ - Stop libpq using the pgAdmin service account's own client files in server mode: when a server had no password file, client certificate or client key configured, libpq fell back to the service account's ``~/.pgpass``, ``~/.postgresql/postgresql.crt`` and ``~/.postgresql/postgresql.key`` (or ``PGPASSFILE``, ``PGSSLCERT`` and ``PGSSLKEY``), letting any user connect with the service account's saved passwords or client certificate (CVE-2026-116247). In server mode each of these parameters is now always given a value, the user's own file or a path that can never be opened, in the connection string and for the utilities. Server mode deployments relying on the service account's files, or on ``passfile``, ``sslcert`` or ``sslkey`` in a connection service entry, must configure them on each server instead, and a server with a client certificate must also have a client key.
|
||||
| `Issue #10653 <https://github.com/pgadmin-org/pgadmin4/issues/10653>`_ - Fix server passwords being left in pgAdmin's own process environment, where the password handed to psql and to the utilities was written into ``os.environ`` and never removed, so another user's later psql session or utility inherited it and libpq used it for any connection made without a password of its own (CVE-2026-116246). The password now goes only into the environment of the process that needs it.
|
||||
| `Issue #10654 <https://github.com/pgadmin-org/pgadmin4/issues/10654>`_ - Restrict connection services in server mode, where any user could set a server's service, or a ``service`` connection parameter, to an entry in the service account's connection service file and connect with that entry's credentials, and where a ``PGPASSWORD`` or ``PGSERVICE`` variable set for that account was used for every connection (CVE-2026-116245). A service may now only be used if the administrator lists it in the new ``SERVER_MODE_ALLOWED_SERVICES`` setting, which is empty by default; the ``servicefile`` parameter is refused, and ``PGPASSWORD`` and ``PGSERVICE`` are removed from pgAdmin's environment at startup. ``setup.py load-servers`` also refuses an unlisted service. In server mode, servers that use a service stop connecting until the administrator lists it in ``SERVER_MODE_ALLOWED_SERVICES`` in ``config_local.py``, ideally before upgrading. Listing a service shares its credentials with every pgAdmin user, so list only services intended for everyone.
|
||||
| `Issue #10655 <https://github.com/pgadmin-org/pgadmin4/issues/10655>`_ - Stop libpq using the pgAdmin service account's Kerberos credentials in server mode, where removing ``KRB5CCNAME`` let the Kerberos library fall back to the service account's default credential cache or client keytab (or its logon, through SSPI on Windows), so any user could answer a server's GSSAPI request as the service account's principal (CVE-2026-116244). In server mode ``KRB5CCNAME`` and ``KRB5_CLIENT_KTNAME`` now point at a file that cannot exist, except that a user who logged in with Kerberos gets their own delegated ``KRB5CCNAME`` when connecting to a server with Kerberos authentication enabled. Every other connection also gets ``gssencmode=disable`` and, with libpq 16 or later, ``require_auth=!gss,!sspi``; without ``require_auth``, SSPI and gssproxy are not stopped, so upgrade the libpq used by pgAdmin and its utilities to 16 or later. A server whose ``require_auth`` allows only GSSAPI or SSPI is refused unless the user logged in with Kerberos. A service listed in ``SERVER_MODE_ALLOWED_SERVICES`` takes precedence for psql and the utilities, so it must not set ``require_auth``, ``gssencmode`` or ``gsslib``.
|
||||
| `Issue #10656 <https://github.com/pgadmin-org/pgadmin4/issues/10656>`_ - Fix the utility binary-path guard accepting a path under ``SHARED_STORAGE``, which ordinary users can write to, so a user could point a server type's binary path at a file placed there and have pgAdmin execute it (CVE-2026-116243). Every ``SHARED_STORAGE`` entry is now rejected alongside ``STORAGE_DIR``, after expanding ``$DIR`` and ``..`` and resolving symbolic links, so an existing binary path under a shared storage directory stops working. An empty ``STORAGE_DIR`` is not covered. Reported by Harsh Singh.
|
||||
| `Issue #10662 <https://github.com/pgadmin-org/pgadmin4/issues/10662>`_ - Stop SSH tunnels using the pgAdmin service account's own SSH identity, where sshtunnel always loaded the unencrypted private keys in the service account's ``~/.ssh``, read its ``~/.ssh/config`` and could use its ssh-agent, so any user could configure a tunnel that authenticated with the service account's key (CVE-2026-116242). In server mode the tunnel no longer reads those keys or that configuration, or uses the agent, and an identity file that cannot be found in the user's storage is reported. Tunnels relying on the service account's key, config or agent must have an identity file in the user's storage or a password configured on the server.
|
||||
| `Issue #10663 <https://github.com/pgadmin-org/pgadmin4/issues/10663>`_ - Fix OS command injection in the Import/Export Data tool, where a query-based export could pass a crafted query past the ``\copy (...)`` parenthesis-balance guard through a backslash inside a double-quoted identifier, which psql treats as an escape when ``standard_conforming_strings`` is off, exposing a live ``TO PROGRAM`` clause that runs an arbitrary command on the pgAdmin host, an incomplete fix for CVE-2026-17566 (CVE-2026-116241). A backslash inside a double-quoted identifier is now rejected, and so is a backslash or null byte in the schema, table or column names of an export or import, which reached the same ``\copy`` command through ``qtIdent()``. Reported by Yonghwa Lee (Xint by Theori).
|
||||
| `Issue #10674 <https://github.com/pgadmin-org/pgadmin4/issues/10674>`_ - Fix the File Manager and the storage-path tools acting on a different path from the one that was checked (CVE-2026-116240): opening, saving and listing files, download, delete, rename, create-folder and upload could name files outside the user's storage directory, or in another user's directory whose name begins with the same characters. Every File Manager and storage-path caller now acts on the absolute path that was checked, and in server mode on POSIX files are opened relative to the storage directory without following symbolic links, so symbolic links to directories and hard-linked files inside it can no longer be opened, downloaded or renamed through. The storage directory itself can no longer be deleted.
|
||||
| `Issue #10675 <https://github.com/pgadmin-org/pgadmin4/issues/10675>`_ - Fix LDAP and Kerberos authentication logging a user in to a different account that shared their username: Kerberos and LDAP looked the account up by username alone, or accepted the account Flask-Security's internal validation had matched by the email typed, so a Kerberos or LDAP user could be logged in as an Internal account, including the initial administrator, or as another user (CVE-2026-116239). Accounts are now looked up by the authenticated username and the source that authenticated them, and only internal authentication logs in an account from its own form. Webserver users whose username matches an Internal account, and LDAP users with capitals in their username, now log in correctly, and ``setup.py`` can update a user without a role and add one without ``--role``. Users who relied on being logged in to an Internal account of the same name now need an account of their own source. Reported by Yves Soete of Blacksight LLC (@yssoe).
|
||||
| `Issue #10676 <https://github.com/pgadmin-org/pgadmin4/issues/10676>`_ - Fix bypasses of the failed-login limit and of pgAdmin's own login, password change and password reset checks, where a username in another case or a wrong internal password with LDAP or Webserver authentication also configured was never counted, and where Flask-Security's own ``/login``, ``/verify``, ``/change``, ``/reset`` and ``/reset/<token>`` views accepted submissions that skipped the failed-login count and lock, the change password permission, the re-encryption of saved passwords, and the internal-account check on resets (CVE-2026-116238). Every failed internal login is now counted (when ``MAX_LOGIN_ATTEMPTS`` is set) and only internal authentication is locked, so a locked Internal account no longer blocks other sources. A login form that posts a password is no longer passed to Kerberos, OAuth2 or Webserver authentication, submissions to Flask-Security's views are refused with 405, and password resets act only on the internal account.
|
||||
@@ -77,6 +77,10 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
* Move the switch next to *Only schema* to limit the restoration to
|
||||
schema-level database objects.
|
||||
|
||||
* Move the switch next to *Only statistics* to the *Yes* position to limit
|
||||
the restoration to optimizer statistics. **Note:** This option is visible
|
||||
only for database server greater than or equal to 18.
|
||||
|
||||
.. image:: images/restore_do_not_save.png
|
||||
:alt: Restore dialog do not save section
|
||||
:align: center
|
||||
@@ -110,6 +114,22 @@ tab to provide options related to data or pgAdmin objects that correspond to *pg
|
||||
Table access methods. **Note:** This option is visible only for
|
||||
database server greater than or equal to 15.
|
||||
|
||||
* Move the switch next to *Row security policies* to the *Yes* position to
|
||||
exclude row security policies. **Note:** This option is visible only for
|
||||
database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Data* to the *Yes* position to exclude data from
|
||||
the restore. **Note:** This option is visible only for database server
|
||||
greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Schema* to the *Yes* position to exclude schema
|
||||
(the data definitions) from the restore. **Note:** This option is visible
|
||||
only for database server greater than or equal to 18.
|
||||
|
||||
* Move the switch next to *Statistics* to the *Yes* position to exclude
|
||||
optimizer statistics from the restore. **Note:** This option is visible
|
||||
only for database server greater than or equal to 18.
|
||||
|
||||
.. image:: images/restore_queries.png
|
||||
:alt: Restore dialog queries section
|
||||
:align: center
|
||||
|
||||
@@ -195,7 +195,7 @@ the Python Wheel (you may need to adjust the path to suit your installation):
|
||||
gunicorn --bind 0.0.0.0:80 \
|
||||
--workers=1 \
|
||||
--threads=25 \
|
||||
--chdir /usr/lib/python3.7/dist-packages/pgadmin4 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4 \
|
||||
pgAdmin4:app
|
||||
|
||||
Standalone uWSGI Configuration
|
||||
@@ -210,9 +210,48 @@ the Python Wheel (you may need to adjust the path to suit your installation):
|
||||
uwsgi --http-socket 0.0.0.0:80 \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.7/dist-packages/pgadmin4/ \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4/ \
|
||||
--mount /=pgAdmin4:app
|
||||
|
||||
.. _sub_directory_hosting:
|
||||
|
||||
Hosting in a Sub-Directory
|
||||
--------------------------
|
||||
|
||||
Any of the reverse proxy configurations described below can host pgAdmin in a
|
||||
sub-directory of the server (``/pgadmin4`` in each of the examples) rather than
|
||||
at its root, but doing so requires that the ``SCRIPT_NAME`` environment
|
||||
variable is set for the Gunicorn or uWSGI process, whichever is in use.
|
||||
|
||||
pgAdmin reads ``SCRIPT_NAME`` from the process environment once, at startup,
|
||||
and uses it to set ``APPLICATION_ROOT`` and ``SESSION_COOKIE_PATH``, so that
|
||||
URLs, redirects (including the one issued after a successful login) and
|
||||
session cookies are all generated relative to the sub-directory rather than to
|
||||
the root of the server. Without it, a successful login redirects to the root of
|
||||
the server, which typically presents to the user as a redirect loop.
|
||||
|
||||
This is a separate mechanism from the per-request ``X-Script-Name`` header that
|
||||
the NGINX Gunicorn example below sets, and neither substitutes for the other.
|
||||
It is also separate from uWSGI's ``--manage-script-name``, which only affects
|
||||
the per-request WSGI environment that uWSGI builds; the uWSGI examples below
|
||||
set ``SCRIPT_NAME`` alongside it so that both mechanisms agree, and so that the
|
||||
session cookie is confined to the sub-directory rather than being issued for
|
||||
the root of the server.
|
||||
|
||||
.. _proxy_headers:
|
||||
|
||||
Reverse Proxy Headers
|
||||
---------------------
|
||||
|
||||
pgAdmin honours the ``X-Script-Name`` and ``X-Scheme`` request headers
|
||||
unconditionally, unlike the ``X-Forwarded-*`` headers, which are trusted only
|
||||
to the depth configured by the ``PROXY_X_*_COUNT`` settings. A client that
|
||||
supplied its own ``X-Script-Name`` could therefore change every URL pgAdmin
|
||||
generates, so the reverse proxy must always either overwrite both headers with
|
||||
values of its own or remove them outright, rather than passing on whatever the
|
||||
client sent. Each of the examples below does one or the other, and a proxy
|
||||
configuration not derived from them should do the same.
|
||||
|
||||
NGINX Configuration with Gunicorn
|
||||
---------------------------------
|
||||
|
||||
@@ -228,7 +267,7 @@ command similar to:
|
||||
gunicorn --bind unix:/tmp/pgadmin4.sock \
|
||||
--workers=1 \
|
||||
--threads=25 \
|
||||
--chdir /usr/lib/python3.7/dist-packages/pgadmin4 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4 \
|
||||
pgAdmin4:app
|
||||
|
||||
And configure NGINX:
|
||||
@@ -238,11 +277,24 @@ And configure NGINX:
|
||||
location / {
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:/tmp/pgadmin4.sock;
|
||||
proxy_set_header X-Script-Name "";
|
||||
proxy_set_header X-Scheme "";
|
||||
}
|
||||
|
||||
Alternatively, pgAdmin can be hosted in a sub-directory (/pgadmin4 in this case)
|
||||
on the server. Start Gunicorn as when using the root directory, but configure
|
||||
NGINX as follows:
|
||||
on the server. Start Gunicorn as when using the root directory, but also set the
|
||||
``SCRIPT_NAME`` environment variable for the Gunicorn process, as described in
|
||||
:ref:`sub_directory_hosting`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
SCRIPT_NAME=/pgadmin4 gunicorn --bind unix:/tmp/pgadmin4.sock \
|
||||
--workers=1 \
|
||||
--threads=25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4 \
|
||||
pgAdmin4:app
|
||||
|
||||
Then configure NGINX:
|
||||
|
||||
.. code-block:: nginx
|
||||
|
||||
@@ -250,6 +302,7 @@ NGINX as follows:
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:/tmp/pgadmin4.sock;
|
||||
proxy_set_header X-Script-Name /pgadmin4;
|
||||
proxy_set_header X-Scheme "";
|
||||
}
|
||||
|
||||
NGINX Configuration with uWSGI
|
||||
@@ -267,7 +320,7 @@ command similar to:
|
||||
uwsgi --socket /tmp/pgadmin4.sock \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.7/dist-packages/pgadmin4/ \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4/ \
|
||||
--manage-script-name \
|
||||
--mount /=pgAdmin4:app
|
||||
|
||||
@@ -279,20 +332,23 @@ And configure NGINX:
|
||||
location @pgadmin4 {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:/tmp/pgadmin4.sock;
|
||||
uwsgi_param HTTP_X_SCRIPT_NAME "";
|
||||
uwsgi_param HTTP_X_SCHEME "";
|
||||
}
|
||||
|
||||
Alternatively, pgAdmin can be hosted in a sub-directory (/pgadmin4 in this case)
|
||||
on the server. Start uWSGI, noting that the directory name is specified in the
|
||||
``mount`` parameter:
|
||||
``mount`` parameter, and that the ``SCRIPT_NAME`` environment variable should
|
||||
be set alongside it, as described in :ref:`sub_directory_hosting`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uwsgi --socket /tmp/pgadmin4.sock \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.7/dist-packages/pgadmin4/ \
|
||||
--manage-script-name \
|
||||
--mount /pgadmin4=pgAdmin4:app
|
||||
SCRIPT_NAME=/pgadmin4 uwsgi --socket /tmp/pgadmin4.sock \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4/ \
|
||||
--manage-script-name \
|
||||
--mount /pgadmin4=pgAdmin4:app
|
||||
|
||||
Then, configure NGINX:
|
||||
|
||||
@@ -303,6 +359,161 @@ Then, configure NGINX:
|
||||
location @pgadmin4 {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass unix:/tmp/pgadmin4.sock;
|
||||
uwsgi_param HTTP_X_SCRIPT_NAME "";
|
||||
uwsgi_param HTTP_X_SCHEME "";
|
||||
}
|
||||
|
||||
Caddy Configuration with Gunicorn
|
||||
----------------------------------
|
||||
|
||||
pgAdmin can be hosted by Gunicorn, with Caddy in front of it as a reverse
|
||||
proxy. Note that these examples assume pgAdmin was installed using the Python
|
||||
Wheel (you may need to adjust the path to suit your installation).
|
||||
|
||||
To run with pgAdmin in the root directory of the server, start Gunicorn using
|
||||
a command similar to:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
gunicorn --bind unix:/run/pgadmin4/pgadmin4.sock \
|
||||
--workers=1 \
|
||||
--threads=25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4 \
|
||||
pgAdmin4:app
|
||||
|
||||
And configure Caddy:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
pgadmin.example.com {
|
||||
reverse_proxy unix//run/pgadmin4/pgadmin4.sock {
|
||||
header_up -X-Script-Name
|
||||
header_up -X-Scheme
|
||||
}
|
||||
}
|
||||
|
||||
.. _caddy_socket_location:
|
||||
|
||||
.. note:: The examples deliberately avoid ``/tmp`` for the socket. The systemd
|
||||
unit shipped with Caddy sets ``PrivateTmp=true``, which gives the Caddy
|
||||
process a private ``/tmp`` directory of its own, so a socket created by
|
||||
Gunicorn or uWSGI in the system ``/tmp`` is invisible to Caddy, which will
|
||||
report a 502 error for every request. Any directory outside ``/tmp`` that
|
||||
both processes can access will do, provided the user Caddy runs as has
|
||||
permission to read and write the socket; alternatively, have Gunicorn or
|
||||
uWSGI listen on a TCP port such as ``127.0.0.1:5050`` and proxy to that
|
||||
instead. Whichever directory is used, it must exist and be writable by the
|
||||
user Gunicorn or uWSGI runs as before the socket can be bound; under
|
||||
systemd, ``RuntimeDirectory=pgadmin4`` in the unit that starts Gunicorn or
|
||||
uWSGI will create ``/run/pgadmin4`` on start and remove it on stop. The
|
||||
same applies to the uWSGI examples below.
|
||||
|
||||
.. _caddy_header_stripping:
|
||||
|
||||
.. note:: The two ``header_up`` directives remove any ``X-Script-Name`` and
|
||||
``X-Scheme`` headers that arrive from the client, for the reasons given in
|
||||
:ref:`proxy_headers`. Each of the Caddy examples removes both, except that
|
||||
the Gunicorn sub-directory example sets ``X-Script-Name`` to the
|
||||
sub-directory instead, which likewise replaces whatever the client sent.
|
||||
|
||||
Alternatively, pgAdmin can be hosted in a sub-directory (/pgadmin4 in this
|
||||
case) on the server. Start Gunicorn as when using the root directory, but
|
||||
also set the ``SCRIPT_NAME`` environment variable for the Gunicorn process, as
|
||||
described in :ref:`sub_directory_hosting`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
SCRIPT_NAME=/pgadmin4 gunicorn --bind unix:/run/pgadmin4/pgadmin4.sock \
|
||||
--workers=1 \
|
||||
--threads=25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4 \
|
||||
pgAdmin4:app
|
||||
|
||||
Then configure Caddy. The request path must be passed through to Gunicorn
|
||||
unchanged, so do not strip the /pgadmin4 prefix; the ``X-Script-Name`` header
|
||||
is set to the sub-directory, both to tell pgAdmin which requests are made
|
||||
under it and to replace any value the client may have sent; and the path
|
||||
matcher must include both ``/pgadmin4`` and ``/pgadmin4/*``, so that pgAdmin's
|
||||
own redirect to the bare sub-directory path (with no trailing slash) is also
|
||||
proxied, rather than falling through to Caddy's default (and rather unhelpful)
|
||||
empty response for an unmatched path:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
pgadmin.example.com {
|
||||
@pgadmin path /pgadmin4 /pgadmin4/*
|
||||
handle @pgadmin {
|
||||
reverse_proxy unix//run/pgadmin4/pgadmin4.sock {
|
||||
header_up X-Script-Name /pgadmin4
|
||||
header_up -X-Scheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Caddy Configuration with uWSGI
|
||||
-------------------------------
|
||||
|
||||
Caddy does not speak the native uwsgi protocol used by NGINX's
|
||||
``uwsgi_pass``, so uWSGI must be run in HTTP mode instead, using
|
||||
``--http-socket`` rather than ``--socket``. Note that these examples assume
|
||||
pgAdmin was installed using the Python Wheel (you may need to adjust the
|
||||
path to suit your installation).
|
||||
|
||||
To run with pgAdmin in the root directory of the server, start uWSGI using a
|
||||
command similar to:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uwsgi --http-socket /run/pgadmin4/pgadmin4.sock \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4/ \
|
||||
--mount /=pgAdmin4:app
|
||||
|
||||
And configure Caddy, again keeping the socket out of ``/tmp`` (see
|
||||
:ref:`the note above <caddy_socket_location>`) and stripping the headers
|
||||
pgAdmin trusts unconditionally (see :ref:`the note above
|
||||
<caddy_header_stripping>`):
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
pgadmin.example.com {
|
||||
reverse_proxy unix//run/pgadmin4/pgadmin4.sock {
|
||||
header_up -X-Script-Name
|
||||
header_up -X-Scheme
|
||||
}
|
||||
}
|
||||
|
||||
Alternatively, pgAdmin can be hosted in a sub-directory (/pgadmin4 in this
|
||||
case) on the server. Start uWSGI, noting that the directory name is specified
|
||||
in the ``mount`` parameter, and that the ``SCRIPT_NAME`` environment variable
|
||||
should still be set alongside it, as described in
|
||||
:ref:`sub_directory_hosting`:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
SCRIPT_NAME=/pgadmin4 uwsgi --http-socket /run/pgadmin4/pgadmin4.sock \
|
||||
--processes 1 \
|
||||
--threads 25 \
|
||||
--chdir /usr/lib/python3.13/dist-packages/pgadmin4/ \
|
||||
--manage-script-name \
|
||||
--mount /pgadmin4=pgAdmin4:app
|
||||
|
||||
Then configure Caddy as for the Gunicorn sub-directory example above, except
|
||||
that no ``X-Script-Name`` header needs to be set: ``--manage-script-name`` has
|
||||
uWSGI report the mount point to pgAdmin with every request, as in the NGINX
|
||||
uWSGI example above, so the header is only removed:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
pgadmin.example.com {
|
||||
@pgadmin path /pgadmin4 /pgadmin4/*
|
||||
handle @pgadmin {
|
||||
reverse_proxy unix//run/pgadmin4/pgadmin4.sock {
|
||||
header_up -X-Script-Name
|
||||
header_up -X-Scheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Additional Information
|
||||
|
||||
@@ -33,6 +33,18 @@ Use the fields in the *General* tab to identify the server:
|
||||
* If the *Shared?* switch is moved to *Yes* then that server can be
|
||||
shared with all the other users. This option is available only to admin users.
|
||||
For more information on users see :ref:`User Management Dialog <user_management>`.
|
||||
Sharing a server, whether in this dialog, through the API or by importing a
|
||||
servers.json file, is restricted to users with the *Administrator* role.
|
||||
Servers that were shared by non-administrator users before this restriction
|
||||
was introduced remain shared until they are un-shared, so administrators
|
||||
should audit them: look for servers with the shared flag set in the
|
||||
configuration database whose owner does not have the *Administrator* role.
|
||||
pgAdmin logs a warning for each such server when it starts in server mode.
|
||||
Such a server can be un-shared by its owner through the server API (a PUT
|
||||
request on the server with ``"shared": false``), or by an administrator
|
||||
changing the ``shared`` column of the server record in the pgAdmin
|
||||
configuration database. The *Shared?* switch in this dialog is shown only
|
||||
to administrators, and only the owner of a server can change it.
|
||||
Users can access the shared servers with some restrictions - the following
|
||||
operations on shared servers are not permitted:
|
||||
|
||||
@@ -41,6 +53,17 @@ Use the fields in the *General* tab to identify the server:
|
||||
* Rename the group server
|
||||
* Change of host, port, and maintenance database
|
||||
|
||||
Each user of a shared server has their own copy of some of its settings.
|
||||
Tags, colours, the role and the service name are copied from the owner
|
||||
the first time a user opens the server, and each user can then change
|
||||
their copy. The host, port, maintenance database and the general
|
||||
connection parameters (such as *connect_timeout* or *sslmode*) always
|
||||
come from the owner's server definition, so a change the owner makes
|
||||
to them is seen by every user. The owner's password, SSH tunnel
|
||||
credentials, SSL certificate, key and CRL paths, and password file
|
||||
(*passfile*) are never shared: each user must supply their own in the
|
||||
server dialog if they need them.
|
||||
|
||||
Please note that once the server is shared, it's icon is changed in the
|
||||
object explorer.
|
||||
|
||||
@@ -85,6 +108,17 @@ Use the fields in the *Connection* tab to configure a connection:
|
||||
see
|
||||
`Section 33.16 of the Postgres documentation <https://www.postgresql.org/docs/current/libpq-pgservice.html>`_.
|
||||
|
||||
In server mode, services are read from the connection service file of the
|
||||
account that pgAdmin runs as, so a server can use only a service that the
|
||||
administrator has listed in the ``SERVER_MODE_ALLOWED_SERVICES`` setting
|
||||
(see :ref:`config_py`), which is empty by default. A server that uses any
|
||||
other service cannot be saved, and an existing one fails to connect with an
|
||||
error naming the setting. Listing a service shares its credentials, such as
|
||||
a password in its entry, with every pgAdmin user. The ``servicefile``
|
||||
connection parameter cannot be used in server mode, and pgAdmin ignores any
|
||||
``PGPASSWORD`` or ``PGSERVICE`` environment variable set for the account it
|
||||
runs as.
|
||||
|
||||
Click the *Parameters* tab to continue.
|
||||
|
||||
.. image:: images/server_parameters.png
|
||||
@@ -107,6 +141,10 @@ Click on the *+* button to add a new parameter. Some of the parameters are:
|
||||
in seconds. Zero or not specified means wait indefinitely. It is not
|
||||
recommended to use a timeout of less than 2 seconds. By default it is set to
|
||||
10 seconds.
|
||||
* *Require authentication* (*require_auth*) field to specify the
|
||||
authentication methods the server may use. In server mode, pgAdmin
|
||||
refuses the GSSAPI and SSPI methods for a connection that may not use
|
||||
Kerberos, as described in :ref:`kerberos`.
|
||||
* *SSL mode* field to select the type of SSL connection the server should use.
|
||||
For more information about using SSL encryption, see
|
||||
`Section 33.18 of the Postgres documentation <https://www.postgresql.org/docs/current/libpq-ssl.html>`_.
|
||||
@@ -145,6 +183,20 @@ icon that is located to the right of each of the following fields.
|
||||
means that administrators of the server may be able to access those files;
|
||||
appropriate caution should be taken before choosing to use this feature.
|
||||
|
||||
.. note:: In Server mode, every connection file path (the *Password file*,
|
||||
client certificate and key, root certificate, certificate revocation list,
|
||||
CRL directory and SSL key log file) must resolve inside your storage
|
||||
directory, or inside a *SHARED_STORAGE* entry (see :ref:`config_py`)
|
||||
written as *Name:/path*. A path that leaves it through *..* or a symbolic
|
||||
link is refused with an "Access denied (...): the path resolves outside
|
||||
your storage directory..." message when connecting or starting a utility.
|
||||
If you used a symbolic link in your storage directory to reach, for
|
||||
example, */etc/pgadmin/certs*, configure that directory as a
|
||||
*SHARED_STORAGE* entry instead (see :ref:`storage_manager`). Every pgAdmin
|
||||
user can use a file in shared storage, so it should hold only shareable
|
||||
files such as a root certificate or CRL, never a private key or a
|
||||
*.pgpass* file.
|
||||
|
||||
Click the *SSH Tunnel* tab to continue.
|
||||
|
||||
.. image:: images/server_ssh_tunnel.png
|
||||
@@ -175,6 +227,9 @@ not be able to connect directly.
|
||||
|
||||
* If the SSH host is expecting a private key file for authentication, use the
|
||||
*Identity file* field to specify the location of the key file.
|
||||
In server mode, pgAdmin does not use the private keys, ssh-agent or
|
||||
``~/.ssh/config`` of the account pgAdmin runs as; the identity file must be
|
||||
located inside your storage directory.
|
||||
* If the SSH host is expecting a password of the user name or an identity file
|
||||
if being used, use the *Password* field to specify the password.
|
||||
* Check the box next to *Prompt for password?* to to have pgAdmin prompt for
|
||||
@@ -211,7 +266,7 @@ Use the fields in the *Advanced* tab to configure a connection:
|
||||
that will be used against the pg_database table to limit the databases that you see.
|
||||
For example, you might enter: *live_db test_db* so that only live_db and test_db
|
||||
are shown in the pgAdmin object explorer.
|
||||
* Use the *Password exec command* field to specify a shell command to be executed
|
||||
* In desktop mode, use the *Password exec command* field to specify a shell command to be executed
|
||||
to retrieve a password to be used for SQL authentication. The ``stdout`` of the
|
||||
command will be used as the SQL password. This may be useful when the password
|
||||
should be generated as a transient authorization token instead of providing a
|
||||
@@ -219,8 +274,36 @@ Use the fields in the *Advanced* tab to configure a connection:
|
||||
You can pass server hostname, port and DB username to the password exec command as variable by providing placeholders
|
||||
like ``%HOSTNAME%``, ``%PORT%`` and ``%USERNAME%`` which will be replace with the server connection information.
|
||||
Example: ``/path/to/script --hostname %HOSTNAME% --port %PORT% --username %USERNAME%``
|
||||
* In server mode, use the *Password exec command* drop-down list to select one
|
||||
of the commands that the administrator has defined in the
|
||||
``SERVER_PASSEXEC_COMMANDS`` setting (see :ref:`config_py`). Users cannot
|
||||
enter their own command. The list offers *None* and each configured command
|
||||
name, and is hidden if no commands have been configured. On a shared server,
|
||||
users who do not own the server also see *Inherit from owner*, which is the
|
||||
default for them and uses whatever command the owner has selected; selecting
|
||||
*None* or a named command overrides it for that user only. If a selected
|
||||
command is later removed from the configuration, the server is treated as
|
||||
having no command and a warning is logged.
|
||||
|
||||
The command is run directly, without a shell, and does not receive the full
|
||||
environment of the pgAdmin process. Only ``PATH``, ``HOME``, ``LANG``,
|
||||
``LANGUAGE``, ``TZ``, ``TMPDIR``, ``TMP``, ``TEMP``, ``USER``, ``LOGNAME``
|
||||
and any ``LC_*`` variable are passed through (and, on Windows,
|
||||
``SYSTEMROOT``, ``SYSTEMDRIVE``, ``PATHEXT``, ``COMSPEC`` and ``WINDIR``),
|
||||
together with any variables the administrator has named in the
|
||||
``SERVER_PASSEXEC_ENV_PASSTHROUGH`` setting. The command also receives the
|
||||
following variables describing the connection, which take precedence over
|
||||
any variable of the same name:
|
||||
``PGADMIN_PASSEXEC_HOST``, ``PGADMIN_PASSEXEC_PORT``,
|
||||
``PGADMIN_PASSEXEC_USERNAME`` (the username that connection uses, so a
|
||||
non-owner's own username on a shared server), ``PGADMIN_PASSEXEC_DATABASE``
|
||||
(the maintenance database), ``PGADMIN_PASSEXEC_PGADMIN_USER`` (the pgAdmin
|
||||
user making the connection) and ``PGADMIN_PASSEXEC_AUTH_SOURCE`` (how that
|
||||
user authenticated to pgAdmin). The ``stdout`` of the command is used as the
|
||||
SQL password.
|
||||
* Use the *Password exec expiration* field to specify a maximum age, in seconds,
|
||||
of the password generated with a *Password exec command*. If not specified,
|
||||
of the password generated with a *Password exec command*. In server mode, this field is
|
||||
enabled only when a named command is selected. If not specified,
|
||||
the password will not expire until your pgAdmin session does.
|
||||
Zero means the command will be executed for each new connection or reconnection that is made.
|
||||
If the generated password is not valid indefinitely, set this value to slightly before it will expire.
|
||||
@@ -234,7 +317,34 @@ Use the fields in the *Advanced* tab to configure a connection:
|
||||
.. note:: The password file option is only supported when pgAdmin is using libpq
|
||||
v10.0 or later to connect to the server.
|
||||
|
||||
.. note:: The Password exec option is only supported when pgAdmin is run in desktop mode.
|
||||
.. note:: In server mode, the *Password exec command* setting is available
|
||||
only when the administrator has defined at least one command in
|
||||
``SERVER_PASSEXEC_COMMANDS``. Free-text commands are never run in server
|
||||
mode, and when pgAdmin is upgraded, an existing free-text command is kept
|
||||
only if it exactly matches a configured command (its arguments joined by
|
||||
single spaces), in which case it is converted to that command's name; all
|
||||
others are cleared and a warning is logged. This conversion cannot be
|
||||
undone, so ``SERVER_PASSEXEC_COMMANDS`` must be configured before the
|
||||
first start after upgrading. If it is still empty at that point, the
|
||||
conversion is postponed: existing commands are left in the configuration
|
||||
database unused, a warning giving the number of affected servers is
|
||||
logged at each start, and they are converted or cleared on the first
|
||||
start at which at least one command is configured. Placeholders such as
|
||||
``%HOSTNAME%``, ``%PORT%`` and ``%USERNAME%`` are not substituted in
|
||||
server mode, so a command containing them is converted only if the
|
||||
configured arguments contain the same literal text. On a shared server, a
|
||||
user who does not own the server and whose own command is cleared will
|
||||
then inherit the owner's command, if the owner has one. The
|
||||
``ENABLE_SERVER_PASS_EXEC_CMD`` setting is deprecated and ignored.
|
||||
|
||||
.. warning:: In server mode, only ``PGADMIN_PASSEXEC_PGADMIN_USER`` and
|
||||
``PGADMIN_PASSEXEC_AUTH_SOURCE`` identify the pgAdmin user on whose behalf
|
||||
the command is run. ``PGADMIN_PASSEXEC_HOST``, ``PGADMIN_PASSEXEC_PORT``,
|
||||
``PGADMIN_PASSEXEC_USERNAME`` and ``PGADMIN_PASSEXEC_DATABASE`` are set by
|
||||
whoever edits the server definition; on a shared server, a user who does
|
||||
not own the server sets their own username. Commands should therefore
|
||||
decide whether to issue a password based on the pgAdmin identity, and
|
||||
treat the other four variables as untrusted input.
|
||||
|
||||
Click the *Post Connection SQL* tab to continue.
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
.. _statistics_dialog:
|
||||
|
||||
**************************
|
||||
`Statistics Dialog`:index:
|
||||
**************************
|
||||
|
||||
Use the *Statistics* dialog to define extended statistics on one or more columns
|
||||
(or expressions) of a table. Extended statistics let PostgreSQL collect
|
||||
correlation data across columns that can significantly improve query-plan
|
||||
estimates for queries that filter or group by multiple columns.
|
||||
|
||||
PostgreSQL introduced ``CREATE STATISTICS`` in PostgreSQL 10, and added
|
||||
expression statistics in PostgreSQL 14. This dialog is available for
|
||||
PostgreSQL 14 and later, which covers all currently supported PostgreSQL
|
||||
versions.
|
||||
|
||||
The *Statistics* dialog organizes options across the *General* and *Definition*
|
||||
tabs. The *SQL* tab displays the SQL command generated by your selections.
|
||||
|
||||
.. image:: images/statistics_general.png
|
||||
:alt: Statistics dialog general tab
|
||||
:align: center
|
||||
|
||||
Use the fields in the *General* tab to describe the statistics object:
|
||||
|
||||
* Use the *Name* field to enter a descriptive name. On PostgreSQL 16 and later
|
||||
the name is optional, and the server will generate one from the table and the
|
||||
columns or expressions if you leave it blank.
|
||||
* Use the *Owner* field to select the role that will own the statistics object.
|
||||
* Use the *Schema* field to select the schema in which the statistics object
|
||||
will reside.
|
||||
* Use the *Table* field to select the table on which the statistics will be
|
||||
collected. The list is filtered to tables in the selected schema.
|
||||
* Use the *Columns* field to select two or more columns. Hold *Ctrl* (or *Cmd*
|
||||
on macOS) to select multiple columns. At least two columns are required when
|
||||
collecting column based statistics, although a single column is enough when
|
||||
it is combined with an expression.
|
||||
* Use the *Statistics types* field to choose which kinds of extended statistics
|
||||
to collect:
|
||||
|
||||
* *N-distinct*, which estimates the number of distinct value combinations
|
||||
across the selected columns or expressions.
|
||||
* *Dependencies*, which detects functional dependencies between columns,
|
||||
improving estimates for queries with correlated ``WHERE`` clauses.
|
||||
* *MCV (Most Common Values)*, which records the most common combinations of
|
||||
values.
|
||||
|
||||
If you leave the field empty, PostgreSQL collects every kind it supports.
|
||||
Leave it empty for statistics on a single expression, which do not accept
|
||||
a choice of kinds.
|
||||
|
||||
* Use the *Comment* field to store an optional note about the statistics object.
|
||||
|
||||
Click the *Definition* tab to continue.
|
||||
|
||||
.. image:: images/statistics_definition.png
|
||||
:alt: Statistics dialog definition tab
|
||||
:align: center
|
||||
|
||||
* Use the *Expressions* field to enter one or more SQL expressions separated by
|
||||
commas (for example ``lower(col1), (col1 + col2)``). Each expression must be
|
||||
enclosed in parentheses unless it is a function call, and the list is passed
|
||||
to the server exactly as you enter it, so an expression may itself contain
|
||||
commas. Expressions may be given instead of columns, or alongside them when
|
||||
you want statistics over a mixture of the two.
|
||||
|
||||
When you open the dialog on an existing statistics object, the *Properties*
|
||||
view also reports the statistics target and, for roles with access, the values
|
||||
that ``ANALYZE`` has collected. Those values are read through the
|
||||
``pg_catalog.pg_stats_ext`` view, which only shows them to roles the server
|
||||
allows to see them (the table's owners, on current PostgreSQL releases), so
|
||||
the *Computed Statistics* group is hidden when the current role lacks access.
|
||||
|
||||
Click the *SQL* tab to continue.
|
||||
|
||||
|
||||
Your entries in the *Statistics* dialog generate a SQL command (see an example
|
||||
below). Use the *SQL* tab for review; revisit or switch tabs to make any changes.
|
||||
|
||||
Example
|
||||
*******
|
||||
|
||||
The following is an example of the SQL command generated by user selections in
|
||||
the *Statistics* dialog:
|
||||
|
||||
.. image:: images/statistics_sql.png
|
||||
:alt: Statistics dialog SQL tab
|
||||
:align: center
|
||||
|
||||
* Click the *Info* button (i) to access online help.
|
||||
* Click the *Save* button to save work.
|
||||
* Click the *Close* button to exit without saving work.
|
||||
* Click the *Reset* button to restore configuration parameters.
|
||||
@@ -60,7 +60,7 @@ Provide information about the new pgAdmin role in the row:
|
||||
specified in the *Email* field. This field is disabled if you select any
|
||||
authentication source except *internal*.
|
||||
* Re-enter the password in the *Confirm password* field. This field is disabled
|
||||
if you select *ldap* as authentication source.
|
||||
if you select any authentication source except *internal*.
|
||||
* *Locked* switch cannot be changed if is turned off. It can only be changed
|
||||
when the user is locked by trying unsuccessful login attempts. Turn off the
|
||||
switch if you want to unlock the account.
|
||||
@@ -132,6 +132,56 @@ a role.
|
||||
|
||||
|
||||
|
||||
.. _auth_source_accounts:
|
||||
|
||||
Accounts from different authentication sources
|
||||
##############################################
|
||||
|
||||
pgAdmin treats accounts from different authentication sources as separate
|
||||
accounts, even when they share a username or an email address. Each has its
|
||||
own role, settings and servers:
|
||||
|
||||
* A user authenticated by LDAP, Kerberos, OAuth2 or webserver authentication
|
||||
is only ever logged in to the account with their username *and* that
|
||||
authentication source, never to an account from another source with the
|
||||
same username. If there is no such account, and the source does not create
|
||||
one automatically, the login fails; pgAdmin does not log the user in to an
|
||||
account from another source instead.
|
||||
* An internal login is checked against, and logs in, only the ``INTERNAL``
|
||||
account.
|
||||
* Only an ``INTERNAL`` account has a password, so *Forgotten your password?*
|
||||
resets only the ``INTERNAL`` account with the email address given.
|
||||
|
||||
The account-lockout setting *MAX_LOGIN_ATTEMPTS* applies only to ``INTERNAL``
|
||||
accounts. pgAdmin never locks an account from another authentication source;
|
||||
brute-force protection for those is the responsibility of the directory,
|
||||
identity provider or reverse proxy. When ``INTERNAL`` and another source are
|
||||
both enabled, a sign-in that fails overall and was tried against an
|
||||
``INTERNAL`` account with that username counts towards its lockout; the attempt
|
||||
that reaches the limit reports that the account is now locked. A locked
|
||||
``INTERNAL`` account stops only internal sign-in: a user of another source with
|
||||
the same username can still sign in. On a later sign-in with that username,
|
||||
pgAdmin discloses that the ``INTERNAL`` account is locked only when internal is
|
||||
the only source able to check the password; while another password source such
|
||||
as LDAP is also eligible, it shows a generic failure instead.
|
||||
|
||||
The administrator account created when pgAdmin is first set up always uses the
|
||||
``INTERNAL`` authentication source. It remains in the pgAdmin database when
|
||||
*AUTHENTICATION_SOURCES* does not include *internal*, although it can then no
|
||||
longer be used to log in. If you use external authentication only, create an
|
||||
administrator account for that source, for example for LDAP:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser --auth-source ldap --admin
|
||||
|
||||
and then deactivate the initial ``INTERNAL`` administrator account, so that it
|
||||
cannot be used if internal authentication is enabled again later:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py update-user admin@example.com --inactive
|
||||
|
||||
Using 'setup.py' command line script
|
||||
####################################
|
||||
|
||||
@@ -146,7 +196,7 @@ Using 'setup.py' command line script
|
||||
without Python interpreter.
|
||||
|
||||
Some of the examples:
|
||||
pgadmin4-cli add-user user1@gmail.com password --role 1
|
||||
pgadmin4-cli add-user user1@gmail.com password --role User
|
||||
pgadmin4-cli get-prefs
|
||||
|
||||
Manage Users
|
||||
@@ -156,7 +206,8 @@ Add User
|
||||
*********
|
||||
|
||||
To add user, invoke ``setup.py`` with ``add-user`` command line option, followed by
|
||||
email and password. role and active will be optional fields.
|
||||
email and password. role and active will be optional fields. When no role is
|
||||
given, the user is created with the *User* role.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -166,7 +217,7 @@ email and password. role and active will be optional fields.
|
||||
# role using --role. If both are provided --admin will be used:
|
||||
|
||||
/path/to/python /path/to/setup.py add-user user1@gmail.com password --admin
|
||||
/path/to/python /path/to/setup.py add-user user1@gmail.com password --role Users
|
||||
/path/to/python /path/to/setup.py add-user user1@gmail.com password --role User
|
||||
|
||||
# to specify user's status
|
||||
|
||||
@@ -177,26 +228,28 @@ Add External User
|
||||
*****************
|
||||
|
||||
To add external authentication user, invoke ``setup.py`` with ``add-external-user`` command line option,
|
||||
followed by email, password and authentication source. email, role and status will be optional fields.
|
||||
followed by username, and the authentication source with ``--auth-source`` (*oauth2* if it is not
|
||||
given). email, role and status will be optional fields. When no role is given,
|
||||
the user is created with the *User* role.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com --auth-source ldap
|
||||
|
||||
# to specify an email:
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser ldap --email user1@gmail.com
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser --auth-source ldap --email user1@gmail.com
|
||||
|
||||
# to specify a role, either you can use --admin for Administrator role or provide the
|
||||
# role using --role. If both are provided --admin will be used:
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser ldap --admin
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser ldap --role Users
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser --auth-source ldap --admin
|
||||
/path/to/python /path/to/setup.py add-external-user ldapuser --auth-source ldap --role User
|
||||
|
||||
# to specify user's status
|
||||
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap --active
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com ldap --inactive
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com --auth-source ldap --active
|
||||
/path/to/python /path/to/setup.py add-external-user user1@gmail.com --auth-source ldap --inactive
|
||||
|
||||
Update User
|
||||
***********
|
||||
@@ -211,19 +264,20 @@ email address. password, role and active are updatable fields.
|
||||
# to specify a role, either you can use --admin for Administrator role or provide the
|
||||
# role using --role. If both are provided --admin will be used:
|
||||
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com password --admin
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com password --role Users
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com --admin
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com --role User
|
||||
|
||||
# to specify user's status
|
||||
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com password --active
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com password --inactive
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com --active
|
||||
/path/to/python /path/to/setup.py update-user user1@gmail.com --inactive
|
||||
|
||||
Update External User
|
||||
********************
|
||||
|
||||
To update the external user, invoke ``setup.py`` with ``update-external-user`` command line option,
|
||||
followed by username and auth source. email, password, role and active are updatable fields.
|
||||
followed by username, and the authentication source with ``--auth-source`` (*oauth2* if it is not
|
||||
given). email, role and active are updatable fields.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -234,19 +288,19 @@ followed by username and auth source. email, password, role and active are updat
|
||||
# to specify a role, either you can use --admin for Administrator role or provide the
|
||||
# role using --role. If both are provided --admin will be used:
|
||||
|
||||
/path/to/python /path/to/setup.py update-external-user user1@gmail.com password --role --admin
|
||||
/path/to/python /path/to/setup.py update-external-user user1@gmail.com password --role --role Users
|
||||
/path/to/python /path/to/setup.py update-external-user ldapuser --auth-source ldap --admin
|
||||
/path/to/python /path/to/setup.py update-external-user ldapuser --auth-source ldap --role User
|
||||
|
||||
# to change user's status
|
||||
|
||||
/path/to/python /path/to/setup.py update-user ldapuser --auth-source ldap --active
|
||||
/path/to/python /path/to/setup.py update-user ldapuser --auth-source ldap --inactive
|
||||
/path/to/python /path/to/setup.py update-external-user ldapuser --auth-source ldap --active
|
||||
/path/to/python /path/to/setup.py update-external-user ldapuser --auth-source ldap --inactive
|
||||
|
||||
Delete User
|
||||
***********
|
||||
|
||||
To delete the user, invoke ``setup.py`` with ``delete-user`` command line option, followed by
|
||||
username and auth_source. For Internal users, email adress will be used instead of username. the option ``--yes`` is available to skip the confirmation prompt before deleting the user.
|
||||
username and auth_source. For Internal users, email address will be used instead of username. the option ``--yes`` is available to skip the confirmation prompt before deleting the user.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ and modify the values for the following parameters:
|
||||
:widths: 35, 55
|
||||
|
||||
"AUTHENTICATION_SOURCES", "The default value for this parameter is *internal*.
|
||||
To enable OAUTH2 authentication, you must include *webserver* in the list of values
|
||||
To enable Webserver authentication, you must include *webserver* in the list of values
|
||||
for this parameter. you can modify the value as follows:
|
||||
|
||||
* [‘webserver’]: pgAdmin will use only Webserver authentication.
|
||||
@@ -88,6 +88,9 @@ and modify the values for the following parameters:
|
||||
with the internal administrator's password, fix the affected users'
|
||||
*Authentication source*, then remove *internal* again.
|
||||
|
||||
See :ref:`Accounts from different authentication sources
|
||||
<auth_source_accounts>` for more information.
|
||||
|
||||
Master Password
|
||||
===============
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ Debian and Ubuntu.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
* Debian 11, 12 & 13
|
||||
* Ubuntu 22.04, 24.04 & 25.04
|
||||
The platforms built are the ones in the matrix in
|
||||
`.github/workflows/check-deb-build.yml`; see `pkg/linux/PLATFORMS.md` for how
|
||||
to add or retire one.
|
||||
|
||||
## Build configuration
|
||||
|
||||
|
||||
@@ -16,26 +16,24 @@ PUID=${PUID:-5050}
|
||||
PGID=${PGID:-0}
|
||||
|
||||
# Validate PUID/PGID are numeric and in acceptable range
|
||||
if ! echo "$PUID" | grep -qE '^[0-9]+$'; then
|
||||
if [[ ! $PUID =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: PUID must be a numeric value, got '$PUID'"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$PGID" | grep -qE '^[0-9]+$'; then
|
||||
if [[ ! $PGID =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: PGID must be a numeric value, got '$PGID'"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PUID" -eq 0 ]; then
|
||||
if (( 10#$PUID == 0 )); then
|
||||
echo "ERROR: PUID=0 (root) is not allowed. Use a non-root UID."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if (( EUID == 0 )); then
|
||||
# Ensure a group with the target GID exists
|
||||
if ! getent group "$PGID" > /dev/null 2>&1; then
|
||||
if ! addgroup -g "$PGID" pggroup; then
|
||||
echo "ERROR: Failed to create group with GID=$PGID"
|
||||
exit 1
|
||||
fi
|
||||
if ! getent group "$PGID" &>/dev/null && ! addgroup -g "$PGID" pggroup; then
|
||||
echo "ERROR: Failed to create group with GID=$PGID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reassign the pgadmin user to the desired UID/GID
|
||||
@@ -46,17 +44,17 @@ if [ "$(id -u)" = "0" ]; then
|
||||
|
||||
# Fix ownership of runtime directories BEFORE any initialization
|
||||
for dir in /run/pgadmin /var/lib/pgadmin; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [[ -d $dir ]]; then
|
||||
chown -R "$PUID:$PGID" "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# Fix ownership of individual files (no -R needed)
|
||||
if [ -e /pgadmin4/config_distro.py ]; then
|
||||
if [[ -e /pgadmin4/config_distro.py ]]; then
|
||||
chown "$PUID:$PGID" /pgadmin4/config_distro.py
|
||||
fi
|
||||
|
||||
if [ -d /certs ]; then
|
||||
if [[ -d /certs ]]; then
|
||||
chown -R "$PUID:$PGID" /certs
|
||||
fi
|
||||
|
||||
@@ -66,12 +64,8 @@ else
|
||||
SU_EXEC=""
|
||||
|
||||
# Fixup the passwd file, in case we're on OpenShift
|
||||
if ! whoami > /dev/null 2>&1; then
|
||||
if [ "$(id -u)" -ne 5050 ]; then
|
||||
if [ -w /etc/passwd ]; then
|
||||
echo "${USER_NAME:-pgadminr}:x:$(id -u):0:${USER_NAME:-pgadminr} user:${HOME}:/sbin/nologin" >> /etc/passwd
|
||||
fi
|
||||
fi
|
||||
if ! whoami &>/dev/null && (( EUID != 5050 )) && [[ -w /etc/passwd ]]; then
|
||||
echo "${USER_NAME:-pgadminr}:x:${EUID}:0:${USER_NAME:-pgadminr} user:${HOME}:/sbin/nologin" >>/etc/passwd
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -103,22 +97,22 @@ if grep -q '^NoNewPrivs:[[:space:]]*1' /proc/self/status 2>/dev/null; then
|
||||
restricted=1
|
||||
fi
|
||||
|
||||
if [ "$restricted" = "0" ]; then
|
||||
if (( restricted == 0 )); then
|
||||
cap_bnd=$(awk '/^CapBnd:/ { print $2 }' /proc/self/status 2>/dev/null)
|
||||
if [ -n "$cap_bnd" ] && [ "$(( 0x${cap_bnd} & 0x400 ))" -eq 0 ]; then
|
||||
if [[ -n $cap_bnd ]] && (( (0x$cap_bnd & 0x400) == 0 )); then
|
||||
restricted=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$restricted" = "1" ] || [ ! -x /venv/bin/python3-cap ]; then
|
||||
if (( restricted == 1 )) || [[ ! -x /venv/bin/python3-cap ]]; then
|
||||
PYTHON_BIN=/venv/bin/python3
|
||||
if [ -z "${PGADMIN_LISTEN_PORT}" ]; then
|
||||
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
|
||||
if [[ -z $PGADMIN_LISTEN_PORT ]]; then
|
||||
if [[ -n $PGADMIN_ENABLE_TLS ]]; then
|
||||
export PGADMIN_LISTEN_PORT=8443
|
||||
else
|
||||
export PGADMIN_LISTEN_PORT=8080
|
||||
fi
|
||||
echo "Restricted security context detected; defaulting PGADMIN_LISTEN_PORT to ${PGADMIN_LISTEN_PORT}."
|
||||
echo "Restricted security context detected; defaulting PGADMIN_LISTEN_PORT to $PGADMIN_LISTEN_PORT."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -126,30 +120,30 @@ fi
|
||||
# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of
|
||||
# "$XYZ_DB_PASSWORD" from a file, for Docker's secrets feature)
|
||||
function file_env() {
|
||||
local var="$1"
|
||||
local fileVar="${var}_FILE"
|
||||
local def="${2:-}"
|
||||
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
|
||||
printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar"
|
||||
exit 1
|
||||
fi
|
||||
local val="$def"
|
||||
if [ "${!var:-}" ]; then
|
||||
val="${!var}"
|
||||
elif [ "${!fileVar:-}" ] && [ ! -r "${!fileVar}" ]; then
|
||||
printf >&2 'error: %s is set to "%s" but the file does not exist or is not readable\n' \
|
||||
"$fileVar" "${!fileVar}"
|
||||
exit 1
|
||||
elif [ "${!fileVar:-}" ]; then
|
||||
val="$(< "${!fileVar}")"
|
||||
fi
|
||||
export "$var"="$val"
|
||||
unset "$fileVar"
|
||||
local var="$1"
|
||||
local fileVar="${var}_FILE"
|
||||
local def="${2:-}"
|
||||
if [[ -n ${!var:-} && -n ${!fileVar:-} ]]; then
|
||||
printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar"
|
||||
exit 1
|
||||
fi
|
||||
local val="$def"
|
||||
if [[ -n ${!var:-} ]]; then
|
||||
val="${!var}"
|
||||
elif [[ -n ${!fileVar:-} && ! -r ${!fileVar} ]]; then
|
||||
printf >&2 'error: %s is set to "%s" but the file does not exist or is not readable\n' \
|
||||
"$fileVar" "${!fileVar}"
|
||||
exit 1
|
||||
elif [[ -n ${!fileVar:-} ]]; then
|
||||
val="$(<"${!fileVar}")"
|
||||
fi
|
||||
export "$var"="$val"
|
||||
unset "$fileVar"
|
||||
}
|
||||
|
||||
# Set values for config variables that can be passed using secrets
|
||||
if [ -n "${PGADMIN_CONFIG_CONFIG_DATABASE_URI_FILE}" ]; then
|
||||
file_env PGADMIN_CONFIG_CONFIG_DATABASE_URI
|
||||
if [[ -n $PGADMIN_CONFIG_CONFIG_DATABASE_URI_FILE ]]; then
|
||||
file_env PGADMIN_CONFIG_CONFIG_DATABASE_URI
|
||||
fi
|
||||
file_env PGADMIN_DEFAULT_PASSWORD
|
||||
|
||||
@@ -159,8 +153,8 @@ export CONFIG_DISTRO_FILE_PATH="${PGADMIN_CUSTOM_CONFIG_DISTRO_FILE:-/pgadmin4/c
|
||||
# provided by the user through the PGADMIN_CONFIG_* environment variables.
|
||||
# Only update the file on first launch. The empty file is created only in default path during the
|
||||
# container build so it can have the required ownership.
|
||||
if [ ! -e "${CONFIG_DISTRO_FILE_PATH}" ] || [ "$(wc -m "${CONFIG_DISTRO_FILE_PATH}" 2>/dev/null | awk '{ print $1 }')" = "0" ]; then
|
||||
cat << EOF > "${CONFIG_DISTRO_FILE_PATH}"
|
||||
if [[ ! -e $CONFIG_DISTRO_FILE_PATH || "$(wc -m "$CONFIG_DISTRO_FILE_PATH" 2>/dev/null | awk '{ print $1 }')" == 0 ]]; then
|
||||
cat <<EOF >"$CONFIG_DISTRO_FILE_PATH"
|
||||
CA_FILE = '/etc/ssl/certs/ca-certificates.crt'
|
||||
LOG_FILE = '/dev/null'
|
||||
HELP_PATH = '../../docs'
|
||||
@@ -175,22 +169,21 @@ DEFAULT_BINARY_PATHS = {
|
||||
}
|
||||
EOF
|
||||
|
||||
# This is a bit kludgy, but necessary as the container uses BusyBox/ash as
|
||||
# it's shell and not bash which would allow a much cleaner implementation
|
||||
for var in $(env | grep "^PGADMIN_CONFIG_" | cut -d "=" -f 1); do
|
||||
# Iterate over PGADMIN_CONFIG_* environment variables and writes them to the config file
|
||||
for var in "${!PGADMIN_CONFIG_@}"; do
|
||||
# Get the raw value
|
||||
val=$(eval "echo \"\$$var\"")
|
||||
val="${!var}"
|
||||
# This normalization step is what makes 'true', 'True'
|
||||
case "$(echo "$val" | tr '[:upper:]' '[:lower:]')" in
|
||||
true) val="True" ;;
|
||||
false) val="False" ;;
|
||||
esac
|
||||
echo "${var#PGADMIN_CONFIG_} = $val" >> "${CONFIG_DISTRO_FILE_PATH}"
|
||||
if [[ "${val,,}" =~ ^(true|false)$ ]]; then
|
||||
val="${val,,}"
|
||||
val="${val^}"
|
||||
fi
|
||||
echo "${var#PGADMIN_CONFIG_} = $val" >>"$CONFIG_DISTRO_FILE_PATH"
|
||||
done
|
||||
|
||||
# If running as root with custom config distro path, fix ownership
|
||||
if [ "$(id -u)" = "0" ] && [ "${CONFIG_DISTRO_FILE_PATH}" != "/pgadmin4/config_distro.py" ]; then
|
||||
chown "$PUID:$PGID" "${CONFIG_DISTRO_FILE_PATH}"
|
||||
if (( EUID == 0 )) && [[ $CONFIG_DISTRO_FILE_PATH != "/pgadmin4/config_distro.py" ]]; then
|
||||
chown "$PUID:$PGID" "$CONFIG_DISTRO_FILE_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -203,7 +196,7 @@ fi
|
||||
# "False" default below is preserved so first-launch user setup still
|
||||
# runs (see #9984).
|
||||
external_config_db_exists="False"
|
||||
if [ -n "${PGADMIN_CONFIG_CONFIG_DATABASE_URI}" ]; then
|
||||
if [[ -n $PGADMIN_CONFIG_CONFIG_DATABASE_URI ]]; then
|
||||
result=$(cd /pgadmin4/pgadmin/utils && $SU_EXEC /venv/bin/python3 -c "
|
||||
import os, ast
|
||||
from check_external_config_db import check_external_config_db
|
||||
@@ -214,7 +207,7 @@ except (ValueError, SyntaxError):
|
||||
uri = raw
|
||||
print(check_external_config_db(uri))
|
||||
" 2>/dev/null)
|
||||
if [ -n "$result" ]; then
|
||||
if [[ -n $result ]]; then
|
||||
external_config_db_exists="$result"
|
||||
fi
|
||||
fi
|
||||
@@ -225,55 +218,70 @@ function load_server_json_file() {
|
||||
|
||||
EXTRA_ARGS=""
|
||||
|
||||
if [ "${PGADMIN_REPLACE_SERVERS_ON_STARTUP}" = "True" ]; then
|
||||
if [[ $PGADMIN_REPLACE_SERVERS_ON_STARTUP == "True" ]]; then
|
||||
EXTRA_ARGS="--replace"
|
||||
fi
|
||||
|
||||
if [ -f "${PGADMIN_SERVER_JSON_FILE}" ]; then
|
||||
if [[ -f $PGADMIN_SERVER_JSON_FILE ]]; then
|
||||
# When running in Desktop mode, no user is created
|
||||
# so we have to import servers anonymously
|
||||
if [ "${PGADMIN_CONFIG_SERVER_MODE}" = "False" ]; then
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py load-servers "${PGADMIN_SERVER_JSON_FILE}" ${EXTRA_ARGS}
|
||||
if [[ $PGADMIN_CONFIG_SERVER_MODE == "False" ]]; then
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py load-servers "$PGADMIN_SERVER_JSON_FILE" $EXTRA_ARGS
|
||||
else
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py load-servers "${PGADMIN_SERVER_JSON_FILE}" --user "${PGADMIN_DEFAULT_EMAIL}" ${EXTRA_ARGS}
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py load-servers "$PGADMIN_SERVER_JSON_FILE" --user "$PGADMIN_DEFAULT_EMAIL" $EXTRA_ARGS
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -f /var/lib/pgadmin/pgadmin4.db ] && [ "${external_config_db_exists}" = "False" ]; then
|
||||
if [ -z "${PGADMIN_DEFAULT_EMAIL}" ] || { [ -z "${PGADMIN_DEFAULT_PASSWORD}" ] && [ -z "${PGADMIN_DEFAULT_PASSWORD_FILE}" ]; }; then
|
||||
if [[ ! -f /var/lib/pgadmin/pgadmin4.db && $external_config_db_exists == "False" ]]; then
|
||||
if [[ -z $PGADMIN_DEFAULT_EMAIL || (-z $PGADMIN_DEFAULT_PASSWORD && -z $PGADMIN_DEFAULT_PASSWORD_FILE) ]]; then
|
||||
echo 'You need to define the PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD or PGADMIN_DEFAULT_PASSWORD_FILE environment variables.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate PGADMIN_DEFAULT_EMAIL
|
||||
CHECK_EMAIL_DELIVERABILITY="False"
|
||||
if [ -n "${PGADMIN_CONFIG_CHECK_EMAIL_DELIVERABILITY}" ]; then
|
||||
CHECK_EMAIL_DELIVERABILITY=${PGADMIN_CONFIG_CHECK_EMAIL_DELIVERABILITY}
|
||||
#
|
||||
# The settings and the email address are read inside Python via
|
||||
# os.environ rather than interpolated into the Python source. Boolean
|
||||
# settings are normalised in the same way as config_distro.py above, so
|
||||
# 'true' and 'false' in any case are accepted.
|
||||
is_valid_email=$(cd /pgadmin4/pgadmin/utils && $SU_EXEC /venv/bin/python3 -c "
|
||||
import ast, os, sys
|
||||
from validation_utils import validate_email
|
||||
|
||||
|
||||
def setting(name, default):
|
||||
raw = os.environ.get('PGADMIN_CONFIG_' + name, '')
|
||||
if not raw:
|
||||
return default
|
||||
if raw.lower() in ('true', 'false'):
|
||||
return raw.lower() == 'true'
|
||||
try:
|
||||
return ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
return raw
|
||||
|
||||
|
||||
email_config = {
|
||||
'CHECK_EMAIL_DELIVERABILITY': setting('CHECK_EMAIL_DELIVERABILITY', False),
|
||||
'ALLOW_SPECIAL_EMAIL_DOMAINS': setting('ALLOW_SPECIAL_EMAIL_DOMAINS', []),
|
||||
'GLOBALLY_DELIVERABLE': setting('GLOBALLY_DELIVERABLE', True),
|
||||
}
|
||||
print(f'email config is {email_config}', file=sys.stderr)
|
||||
print(validate_email(os.environ['PGADMIN_DEFAULT_EMAIL'], email_config))
|
||||
") || exit 1
|
||||
if [[ $is_valid_email == *False* ]]; then
|
||||
echo "'$PGADMIN_DEFAULT_EMAIL' does not appear to be a valid email address. Please reset the PGADMIN_DEFAULT_EMAIL environment variable and try again."
|
||||
echo "Validation output: $is_valid_email"
|
||||
exit 1
|
||||
fi
|
||||
ALLOW_SPECIAL_EMAIL_DOMAINS="[]"
|
||||
if [ -n "${PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS}" ]; then
|
||||
ALLOW_SPECIAL_EMAIL_DOMAINS=${PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS}
|
||||
fi
|
||||
GLOBALLY_DELIVERABLE="True"
|
||||
if [ -n "${PGADMIN_CONFIG_GLOBALLY_DELIVERABLE}" ]; then
|
||||
GLOBALLY_DELIVERABLE=${PGADMIN_CONFIG_GLOBALLY_DELIVERABLE}
|
||||
fi
|
||||
email_config="{'CHECK_EMAIL_DELIVERABILITY': ${CHECK_EMAIL_DELIVERABILITY}, 'ALLOW_SPECIAL_EMAIL_DOMAINS': ${ALLOW_SPECIAL_EMAIL_DOMAINS}, 'GLOBALLY_DELIVERABLE': ${GLOBALLY_DELIVERABLE}}"
|
||||
echo "email config is ${email_config}"
|
||||
is_valid_email=$(cd /pgadmin4/pgadmin/utils && $SU_EXEC /venv/bin/python3 -c "from validation_utils import validate_email; val = validate_email('${PGADMIN_DEFAULT_EMAIL}', ${email_config}); print(val)")
|
||||
if echo "${is_valid_email}" | grep "False" > /dev/null; then
|
||||
echo "'${PGADMIN_DEFAULT_EMAIL}' does not appear to be a valid email address. Please reset the PGADMIN_DEFAULT_EMAIL environment variable and try again."
|
||||
echo "Validation output: ${is_valid_email}"
|
||||
exit 1
|
||||
fi
|
||||
# Switch back to root directory for further process
|
||||
cd /pgadmin4
|
||||
|
||||
# Set the default username and password in a
|
||||
# backwards compatible way
|
||||
export PGADMIN_SETUP_EMAIL="${PGADMIN_DEFAULT_EMAIL}"
|
||||
export PGADMIN_SETUP_PASSWORD="${PGADMIN_DEFAULT_PASSWORD}"
|
||||
export PGADMIN_SETUP_EMAIL="$PGADMIN_DEFAULT_EMAIL"
|
||||
export PGADMIN_SETUP_PASSWORD="$PGADMIN_DEFAULT_PASSWORD"
|
||||
|
||||
# Initialize DB before starting Gunicorn
|
||||
# Importing pgadmin4 (from this script) is enough
|
||||
@@ -285,41 +293,41 @@ if [ ! -f /var/lib/pgadmin/pgadmin4.db ] && [ "${external_config_db_exists}" = "
|
||||
load_server_json_file
|
||||
|
||||
# Pre-load any required preferences
|
||||
if [ -f "${PGADMIN_PREFERENCES_JSON_FILE}" ]; then
|
||||
if [ "${PGADMIN_CONFIG_SERVER_MODE}" = "False" ]; then
|
||||
if [[ -f $PGADMIN_PREFERENCES_JSON_FILE ]]; then
|
||||
if [[ $PGADMIN_CONFIG_SERVER_MODE == "False" ]]; then
|
||||
DESKTOP_USER=$(cd /pgadmin4 && $SU_EXEC /venv/bin/python3 -c 'import config; print(config.DESKTOP_USER)')
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py set-prefs "${DESKTOP_USER}" --input-file "${PGADMIN_PREFERENCES_JSON_FILE}"
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py set-prefs "$DESKTOP_USER" --input-file "$PGADMIN_PREFERENCES_JSON_FILE"
|
||||
else
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py set-prefs "${PGADMIN_DEFAULT_EMAIL}" --input-file "${PGADMIN_PREFERENCES_JSON_FILE}"
|
||||
$SU_EXEC /venv/bin/python3 /pgadmin4/setup.py set-prefs "$PGADMIN_DEFAULT_EMAIL" --input-file "$PGADMIN_PREFERENCES_JSON_FILE"
|
||||
fi
|
||||
fi
|
||||
# Copy the pgpass file passed using secrets
|
||||
if [ -n "${PGPASS_FILE}" ] && [ -f "${PGPASS_FILE}" ]; then
|
||||
if [ "${PGADMIN_CONFIG_SERVER_MODE}" = "False" ]; then
|
||||
cp "${PGPASS_FILE}" /var/lib/pgadmin/.pgpass
|
||||
if [[ -n $PGPASS_FILE && -f $PGPASS_FILE ]]; then
|
||||
if [[ $PGADMIN_CONFIG_SERVER_MODE == "False" ]]; then
|
||||
cp "$PGPASS_FILE" /var/lib/pgadmin/.pgpass
|
||||
chmod 600 /var/lib/pgadmin/.pgpass
|
||||
# Fix ownership when running as root
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if (( EUID == 0 )); then
|
||||
chown "$PUID:$PGID" /var/lib/pgadmin/.pgpass
|
||||
fi
|
||||
else
|
||||
PGADMIN_USER_CONFIG_DIR=$(echo "${PGADMIN_DEFAULT_EMAIL}" | sed 's/@/_/g')
|
||||
mkdir -p "/var/lib/pgadmin/storage/${PGADMIN_USER_CONFIG_DIR}"
|
||||
cp "${PGPASS_FILE}" "/var/lib/pgadmin/storage/${PGADMIN_USER_CONFIG_DIR}/.pgpass"
|
||||
chmod 600 "/var/lib/pgadmin/storage/${PGADMIN_USER_CONFIG_DIR}/.pgpass"
|
||||
PGADMIN_USER_CONFIG_DIR="${PGADMIN_DEFAULT_EMAIL//@/_}"
|
||||
mkdir -p "/var/lib/pgadmin/storage/$PGADMIN_USER_CONFIG_DIR"
|
||||
cp "$PGPASS_FILE" "/var/lib/pgadmin/storage/$PGADMIN_USER_CONFIG_DIR/.pgpass"
|
||||
chmod 600 "/var/lib/pgadmin/storage/$PGADMIN_USER_CONFIG_DIR/.pgpass"
|
||||
# Fix ownership when running as root
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R "$PUID:$PGID" "/var/lib/pgadmin/storage/${PGADMIN_USER_CONFIG_DIR}"
|
||||
if (( EUID == 0 )); then
|
||||
chown -R "$PUID:$PGID" "/var/lib/pgadmin/storage/$PGADMIN_USER_CONFIG_DIR"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# If already initialised and PGADMIN_REPLACE_SERVERS_ON_STARTUP is set to true, then load the server json file.
|
||||
elif [ "${PGADMIN_REPLACE_SERVERS_ON_STARTUP}" = "True" ]; then
|
||||
elif [[ $PGADMIN_REPLACE_SERVERS_ON_STARTUP == "True" ]]; then
|
||||
load_server_json_file
|
||||
fi
|
||||
|
||||
# Start Postfix to handle password resets etc.
|
||||
if [ -z "${PGADMIN_DISABLE_POSTFIX}" ]; then
|
||||
if [[ -z $PGADMIN_DISABLE_POSTFIX ]]; then
|
||||
sudo /usr/sbin/postfix start
|
||||
fi
|
||||
|
||||
@@ -330,18 +338,16 @@ TIMEOUT=$(cd /pgadmin4 && $SU_EXEC /venv/bin/python3 -c 'import config; print(co
|
||||
# NOTE: currently pgadmin can run only with 1 worker due to sessions implementation
|
||||
# Using --threads to have multi-threaded single-process worker
|
||||
|
||||
if [ -n "${PGADMIN_ENABLE_SOCK}" ]; then
|
||||
if [[ -n $PGADMIN_ENABLE_SOCK ]]; then
|
||||
BIND_ADDRESS="unix:/run/pgadmin/pgadmin.sock"
|
||||
elif [[ -n $PGADMIN_ENABLE_TLS ]]; then
|
||||
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-443}"
|
||||
else
|
||||
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
|
||||
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-443}"
|
||||
else
|
||||
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-80}"
|
||||
fi
|
||||
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-80}"
|
||||
fi
|
||||
|
||||
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
|
||||
exec $SU_EXEC "${PYTHON_BIN}" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app
|
||||
if [[ -n $PGADMIN_ENABLE_TLS ]]; then
|
||||
exec $SU_EXEC "$PYTHON_BIN" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --timeout "$TIMEOUT" --bind "$BIND_ADDRESS" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app
|
||||
else
|
||||
exec $SU_EXEC "${PYTHON_BIN}" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --limit-request-fields "${GUNICORN_LIMIT_REQUEST_FIELDS:-100}" --limit-request-field_size "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" -c gunicorn_config.py run_pgadmin:app
|
||||
exec $SU_EXEC "$PYTHON_BIN" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --limit-request-fields "${GUNICORN_LIMIT_REQUEST_FIELDS:-100}" --limit-request-field_size "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE:-8190}" --timeout "$TIMEOUT" --bind "$BIND_ADDRESS" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" -c gunicorn_config.py run_pgadmin:app
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: v2
|
||||
name: pgadmin4-helm
|
||||
version: 9.18.0
|
||||
appVersion: 9.18.0
|
||||
version: 9.19.0
|
||||
appVersion: 9.19.0
|
||||
description: pgAdmin is the most popular and feature rich Open Source administration and development platform for PostgreSQL, the most advanced Open Source database in the world.
|
||||
home: https://pgadmin.org
|
||||
sources: ["https://github.com/pgadmin-org/pgadmin4/tree/master/helm"]
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Adding and retiring Linux platforms
|
||||
|
||||
Debian, Ubuntu, Fedora and EL releases come and go several times a year, and
|
||||
each one is named in more places than is obvious: the build matrices, the
|
||||
staging and release workflows, the acceptance tests and the publishing wrapper
|
||||
on the servers all carry their own list. Missing one does not always fail
|
||||
loudly (a platform the wrapper does not know about is built, uploaded and then
|
||||
quietly left unindexed), so work through the whole of the relevant section
|
||||
below rather than stopping when CI goes green.
|
||||
|
||||
Architectures are a different change, and are not covered here; see the
|
||||
comments on the RPM tree in `.github/workflows/snapshot.yml`, `YUM_TARGETS` in
|
||||
`pkg/publish/pga-publish` and `Architectures` in `pkg/publish/aptftp.conf`.
|
||||
|
||||
## Where platforms are named
|
||||
|
||||
| File | What it holds | Naming |
|
||||
|---|---|---|
|
||||
| `.github/workflows/check-deb-build.yml` | The DEB build matrix, which the snapshot and release candidate workflows call | `debian-13`, `ubuntu-2604`, plus the container image |
|
||||
| `.github/workflows/check-rpm-build.yml` | The RPM build matrix, likewise | `el-10`, `fedora-44`, plus the image and the PGDG repository RPM URL |
|
||||
| `.github/workflows/snapshot.yml` | `codename_for()`, mapping DEB build names to codenames; the `case` mapping RPM build names to repository trees | `ubuntu-2604` to `resolute`; `el-10` to `redhat/rhel-10` |
|
||||
| `.github/workflows/release-candidate.yml` | The same two mappings, duplicated | as above |
|
||||
| `.github/workflows/acceptance-test.yml` | The install test matrices, which add the published repositories and install from them | `resolute`; `rhel-10`, `fedora-44` |
|
||||
| `.github/workflows/promote-release.yml` | The `rebuild-apt` and `rebuild-yum` calls in "Publish the packages" | `resolute`; `redhat rhel 10 x86_64` |
|
||||
| `pkg/publish/pga-publish` | `APT_CODENAMES` and `YUM_TARGETS`, which decide what the servers create, index and promote | as `promote-release.yml` |
|
||||
|
||||
These do not need changing for a new release of an existing distribution:
|
||||
|
||||
* The repository configuration RPMs (`pkg/redhat/repo-rpm.sh`), which use
|
||||
`$releasever` rather than naming versions.
|
||||
* The repository READMEs, whose platform tables `install-repo-readme.py`
|
||||
derives from the trees on disk and the archive bucket.
|
||||
* The DEB and RPM build scripts, unless the new release needs something
|
||||
different; see 'Version-specific code' below.
|
||||
|
||||
## Adding a platform
|
||||
|
||||
### Before starting
|
||||
|
||||
Check that the pieces the build depends on exist for the new release:
|
||||
|
||||
* The container image on Docker Hub (`ubuntu:26.04`, `fedora:45`,
|
||||
`almalinux:11`). New Ubuntu releases usually have a tag some weeks before
|
||||
release; building against a pre-release image is fine for proving the build,
|
||||
but do not publish packages from one.
|
||||
* For RPM platforms, the PGDG repository RPM
|
||||
(`https://download.postgresql.org/pub/repos/yum/reporpms/`), since the build
|
||||
takes `libpq5-devel` and `postgresql18-devel` from PGDG. On EL, EPEL must
|
||||
also exist for the new version. DEB builds use the distribution's own
|
||||
`libpq-dev` and have no equivalent dependency.
|
||||
* The codename, for DEB platforms: it is what `lsb_release -cs` prints and
|
||||
what the repository directory is called, so take it from the release itself
|
||||
rather than from the version number.
|
||||
|
||||
### In this repository
|
||||
|
||||
All of this goes in one pull request. The DEB and RPM checks do not run on
|
||||
pull requests, only on pushes to `master` and on dispatch, so prove the new
|
||||
matrix leg before merging by dispatching `check-deb-build.yml` or
|
||||
`check-rpm-build.yml` against the branch in a fork with Actions enabled
|
||||
(`gh workflow run check-deb-build.yml --ref <branch>`).
|
||||
|
||||
1. Add the matrix leg to `check-deb-build.yml` or `check-rpm-build.yml`,
|
||||
copying the neighbouring entry's form exactly. The `name` becomes part of
|
||||
the artifact name, and the mappings below parse it back out.
|
||||
2. Add the mapping for the new name to `snapshot.yml` and to
|
||||
`release-candidate.yml`. A DEB name with no `codename_for()` entry fails
|
||||
the assemble job with `Unknown Debian build name`; an RPM name that is not
|
||||
`el-*` or `fedora-*` fails with `Unknown RPM build name`.
|
||||
3. Add the platform to both matrices in `acceptance-test.yml`.
|
||||
4. Add the `rebuild-apt` codename, or the `rebuild-yum` line, to "Publish the
|
||||
packages" in `promote-release.yml`.
|
||||
5. Add the codename to `APT_CODENAMES`, or the tuple to `YUM_TARGETS`, in
|
||||
`pkg/publish/pga-publish`. The wrapper refuses to index anything not in
|
||||
these tables, and the snapshot and staging indexers only look for the trees
|
||||
they name, so without this the new packages are uploaded but never indexed,
|
||||
and `packages-fetch` never carries them into the live repository.
|
||||
|
||||
If the build needs a platform-specific change (a package renamed, a new
|
||||
AppArmor rule, a different Python), make it in the same pull request, so that
|
||||
the new matrix leg proves it.
|
||||
|
||||
### On the servers
|
||||
|
||||
`pga-publish` and the scripts beside it are installed by hand on both the
|
||||
staging and the download servers, as described under "Installing" in
|
||||
`pkg/publish/README.md`. Reinstall them on both once the pull request is
|
||||
merged, and before the next snapshot, since the snapshot and release candidate
|
||||
workflows build the new platform from then on and the servers will skip what
|
||||
their installed copy of the tables does not name.
|
||||
|
||||
### Proving it
|
||||
|
||||
1. Dispatch a publishing snapshot (`gh workflow run snapshot.yml -f
|
||||
publish=true`), and check that the new platform's tree in the snapshot has
|
||||
signed indices (`InRelease` for APT, `repodata/repomd.xml.asc` for YUM), and that
|
||||
the snapshot's README lists it.
|
||||
2. Install from the snapshot on the new platform, following that README.
|
||||
3. The first release candidate's acceptance test installs it from the staging
|
||||
repository, which is the check that matters before promotion.
|
||||
|
||||
### The first release that carries it
|
||||
|
||||
`rebuild-readme` builds the production README's history from the archive
|
||||
bucket, and exits with `<platform> is published but absent from the archive`
|
||||
when a platform in the live tree has never been copied there. Promotion runs
|
||||
`rebuild-readme` in "Publish the packages", before `sync-s3` copies the
|
||||
release to the archive in "Update the website", so the first promotion after
|
||||
adding a platform fails at that point, with the packages already live but the
|
||||
website not updated. Until that ordering is changed, expect it: run
|
||||
`pga-publish sync-s3` by hand on the download server, then rerun
|
||||
`pga-publish rebuild-readme` for the affected kind and finish the website
|
||||
steps.
|
||||
|
||||
### Elsewhere
|
||||
|
||||
The download pages on www.pgadmin.org list the supported platforms, and live
|
||||
outside this repository. Open an issue for the platform, too, so that the
|
||||
pre-release pass picks it up for the release notes, as with `Issue #9263`
|
||||
("Added Ubuntu 25.10 and Fedora 43") in `release_notes_9_11.rst`.
|
||||
|
||||
## Retiring a platform
|
||||
|
||||
A platform is retired when it stops receiving builds. Its repository keeps
|
||||
serving the last packages it had until somebody removes the tree, and the
|
||||
archive bucket keeps them after that, since `sync-s3` never deletes.
|
||||
|
||||
### In this repository
|
||||
|
||||
Reverse steps 1 to 5 of adding a platform, in a single pull request: the
|
||||
matrix leg, both mappings, both acceptance test entries, the promotion
|
||||
`rebuild-*` line and the wrapper table entry. Then remove any version-specific
|
||||
code that only existed for that platform (see below).
|
||||
|
||||
Keep the steps together. A platform removed from the wrapper tables but still
|
||||
in the build matrices is built and uploaded, then silently left out of the
|
||||
indices and the promotion.
|
||||
|
||||
Reinstall the wrapper on both servers once it is merged, as when adding one.
|
||||
|
||||
### On the download server
|
||||
|
||||
Nothing is removed automatically, and the production README will go on
|
||||
listing the platform as supported for as long as its tree holds packages,
|
||||
since it takes the supported list from the trees that exist. To move it to the
|
||||
"no longer supported" table:
|
||||
|
||||
1. Make sure the archive bucket has the platform's last release, which it will
|
||||
if `sync-s3` has run since that release was promoted.
|
||||
2. Remove the tree from the live repository: `apt/<codename>` for APT, or
|
||||
`yum/<family>/<name>-<version>-<arch>` and its `Server`, `Workstation` and
|
||||
`Client` compatibility links for YUM.
|
||||
3. Run `pga-publish rebuild-readme apt` or `yum`.
|
||||
|
||||
When to do this is a policy decision rather than a mechanical one: users who
|
||||
have not upgraded their distribution lose `apt update`/`dnf update` access to
|
||||
pgAdmin the moment the tree goes.
|
||||
|
||||
## Version-specific code
|
||||
|
||||
The build scripts branch on the distribution version in a few places. Check
|
||||
them when adding a platform, and delete the dead branches when retiring one:
|
||||
|
||||
* `pkg/debian/build.sh`: the AppArmor profile and `postinst` for Ubuntu 24 and
|
||||
later.
|
||||
* `pkg/linux/build-functions.sh`: the EL 8 Python selection, in `_setup_env`
|
||||
and `_copy_code`.
|
||||
* `pkg/redhat/setup.sh` and `pkg/debian/setup.sh`: prerequisites for building
|
||||
by hand. CI does not run these (the workflows install their own), so they
|
||||
are the likeliest to be out of date; `pkg/redhat/setup.sh`, for example,
|
||||
only knows EL 8 and 9.
|
||||
* The comments in `check-deb-build.yml` and `check-rpm-build.yml`, which name
|
||||
the releases their workarounds apply to (PEP 668, dnf5, the distribution's
|
||||
Node.js on Ubuntu 26.04).
|
||||
@@ -5,3 +5,6 @@ various Linux distributions.
|
||||
|
||||
You should not use them directly, but the functionality provided can be used to
|
||||
simplify your own build scripts (typically by sourcing *build-functions.sh*).
|
||||
|
||||
Adding or retiring a Linux platform touches more than the build scripts; see
|
||||
*PLATFORMS.md* for everywhere a platform is named.
|
||||
@@ -102,6 +102,16 @@ _create_python_virtualenv() {
|
||||
# file inside the venv's own site-packages (which we do own), added
|
||||
# once pip/wheel are installed below.
|
||||
"${SYSTEM_PYTHON_PATH}" -m venv venv
|
||||
|
||||
# Python 3.14's venv module adds a novelty "𝜋thon" alias (U+1D70B, see
|
||||
# CPython gh-119535) alongside python/python3/python3.X. Nothing uses it,
|
||||
# and the "sed -i" hash bang fixup below turns the symlink into a full
|
||||
# copy of the interpreter which, unlike the python* links, is never put
|
||||
# back. dpkg on Ubuntu 26.04 then fails to unpack it with EPERM (#10414),
|
||||
# so remove it. The name is given as raw UTF-8 bytes so that it matches
|
||||
# whatever the build shell's locale is.
|
||||
rm -f "venv/bin/"$'\360\235\234\213'"thon"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. venv/bin/activate
|
||||
|
||||
@@ -137,6 +147,11 @@ _create_python_virtualenv() {
|
||||
--no-binary psycopg -r "${SOURCEDIR}/requirements.txt"
|
||||
fi
|
||||
|
||||
# Remove the files in the installed packages that we never use, and the
|
||||
# packaging tools (pip, setuptools and wheel) that are only needed to
|
||||
# build the environment
|
||||
python3 "${SOURCEDIR}/pkg/prune_site_packages.py" venv || exit 1
|
||||
|
||||
# Fixup the paths in the venv activation scripts
|
||||
sed -i 's/VIRTUAL_ENV=.*/VIRTUAL_ENV="\/usr\/pgadmin4\/venv"/g' venv/bin/activate
|
||||
sed -i 's/setenv VIRTUAL_ENV .*/setenv VIRTUAL_ENV "\/usr\/pgadmin4\/venv"/g' venv/bin/activate.csh
|
||||
@@ -202,14 +217,32 @@ _build_runtime() {
|
||||
ELECTRON_ARCH="arm64"
|
||||
fi
|
||||
|
||||
# Resolve the electron version from runtime/package.json, NOT from
|
||||
# `npm info electron version`. The latter fetches whatever currently
|
||||
# carries the `latest` dist-tag on the npm registry, which means any
|
||||
# newly published electron release lands in shipped binaries without
|
||||
# review. Keep the build deterministic and pinned.
|
||||
ELECTRON_VERSION=$(sed -nE 's/.*"electron":[[:space:]]*"\^?([0-9.]+)".*/\1/p' "${SOURCEDIR}/runtime/package.json" | head -1)
|
||||
# Resolve the electron version from runtime/yarn.lock, NOT from the npm
|
||||
# registry and NOT from the range in runtime/package.json. The registry's
|
||||
# `latest` dist-tag lands any newly published electron release in shipped
|
||||
# binaries without review, whilst the package.json range is only a lower
|
||||
# bound, so yarn is free to resolve it to a build other than the one we
|
||||
# ship. The lockfile is the single source of truth, and `yarn info` reports
|
||||
# its resolution without touching the network or needing node_modules.
|
||||
#
|
||||
# It must run inside runtime/ though: outside a yarn project, `yarn info`
|
||||
# silently falls back to querying the registry and still exits 0, so check
|
||||
# the lockfile is present first and validate what comes back.
|
||||
if [ ! -f "${SOURCEDIR}/runtime/yarn.lock" ]; then
|
||||
echo "ERROR: ${SOURCEDIR}/runtime/yarn.lock not found; cannot resolve the pinned electron version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ELECTRON_VERSION=$(cd "${SOURCEDIR}/runtime" && yarn info electron --json | node -e "
|
||||
const lines = require('fs').readFileSync(0, 'utf8').split('\n').filter(Boolean);
|
||||
const pkg = lines.map(l => { try { return JSON.parse(l); } catch (e) { return null; } })
|
||||
.find(o => o && typeof o.value === 'string' && o.value.startsWith('electron@npm:'));
|
||||
const version = (pkg && pkg.children && pkg.children.Version) || '';
|
||||
process.stdout.write(/^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?$/.test(version) ? version : '');
|
||||
")
|
||||
|
||||
if [ -z "${ELECTRON_VERSION}" ]; then
|
||||
echo "ERROR: could not resolve electron version from runtime/package.json" >&2
|
||||
echo "ERROR: could not resolve the pinned electron version from ${SOURCEDIR}/runtime/yarn.lock" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -304,7 +337,7 @@ _copy_code() {
|
||||
exit 1
|
||||
fi
|
||||
yarn set version "${YARN_VERSION}"
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
yarn run bundle
|
||||
popd > /dev/null || exit
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<string>%APPNAME%</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>app.icns</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>pgAdmin4</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>%APPID%</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
|
||||
@@ -10,13 +10,15 @@ Either build the sources or get them from macports or similar:
|
||||
dependencies from the
|
||||
[pgbuild project](https://github.com/pgadmin-org/pgbuild), which is what the
|
||||
release builds use. Five are needed, openssl, krb5, zstd, lz4 and
|
||||
postgresql-18, each published as a release whose tag ends in *-latest* so
|
||||
that the current build is always at a predictable URL:
|
||||
postgresql-18, each published as a release of its own:
|
||||
|
||||
https://github.com/pgadmin-org/pgbuild/releases/download/<pkg>-macos-<arch>-latest/<pkg>-macos-<arch>-latest.tar.gz
|
||||
https://github.com/pgadmin-org/pgbuild/releases/download/<tag>/<tag>.tar.gz
|
||||
|
||||
where <arch> is arm64 or x86_64. They unpack into subdirectories of a
|
||||
common prefix, /opt/pgbuild in CI, which is then given to the build as
|
||||
where <tag> is the release tag named in *pkg/pgbuild-deps.lock*, on the
|
||||
*<pkg>-macos-<arch>* line, and <arch> is arm64 or x86_64. Use those rather
|
||||
than the rolling *-latest* tags, so that what you build locally is what CI
|
||||
builds and what we ship. They unpack into subdirectories of a common prefix,
|
||||
/opt/pgbuild in CI, which is then given to the build as
|
||||
*PGADMIN_POSTGRES_DIR=/opt/pgbuild/postgresql*.
|
||||
|
||||
These matter beyond convenience: they are built against each other with
|
||||
@@ -35,12 +37,65 @@ Either build the sources or get them from macports or similar:
|
||||
brew install syft wget
|
||||
|
||||
|
||||
## Pinned dependencies
|
||||
|
||||
*pkg/pgbuild-deps.lock* records, for every dependency the
|
||||
*install-pgbuild-deps* action can fetch on either platform, the pgbuild
|
||||
release tag to download and the SHA-256 the archive must hash to. The action
|
||||
checks each download against it and refuses to unpack anything that does not
|
||||
match, which matters because a macOS appbundle is Developer ID signed and
|
||||
notarised and a Windows installer Authenticode signed, so without the lock
|
||||
write access to pgbuild would be write access to what pgAdmin ships, and two
|
||||
builds of the same commit would not contain the same binaries.
|
||||
|
||||
When pgbuild publishes a new build of something, the action says so as a
|
||||
warning and carries on with the pinned version, since a dependency that
|
||||
appeared overnight is not a reason to fail a build. To take the new one, run
|
||||
the refresh tool, naming the package so that nothing else is disturbed, then
|
||||
read the diff and commit it:
|
||||
|
||||
tools/refresh_pgbuild_lock.py openssl
|
||||
|
||||
That diff, one line per dependency with the version visible in the tag, is
|
||||
the record of a change to what we sign, and is the reason to bump
|
||||
dependencies through the tool rather than by editing the lock by hand.
|
||||
|
||||
A checksum mismatch fails the build, and the message names the archive, the
|
||||
expected digest and the one that arrived. Most such tags are per-version and
|
||||
so never change, the exception being PostgreSQL, whose tag carries only the
|
||||
major and is therefore rebuilt in place on every minor release. Satisfy
|
||||
yourself that a rebuild is what happened before re-running, since this is also
|
||||
what would catch a tampered dependency on its way into a signed build. An
|
||||
archive with no entry in the lock fails the same way, rather than being
|
||||
unpacked unverified.
|
||||
|
||||
pgbuild publishes a *.sha256* beside each archive. That is a convenience for
|
||||
people, not a source of trust, since whoever rebuilds an archive rebuilds its
|
||||
checksum too, so the action verifies against the lock instead.
|
||||
|
||||
|
||||
## Application icon
|
||||
|
||||
*pgAdmin4.icon* is an Icon Composer document holding the layered icon that
|
||||
macOS 26 and later use for the light, dark, clear and tinted appearances.
|
||||
It is compiled ahead of time into *Assets.car*, which is committed so that the
|
||||
build does not need Xcode 26; *pgAdmin4.icns* remains the icon for older
|
||||
releases. After editing *pgAdmin4.icon*, regenerate *Assets.car* from this
|
||||
directory with:
|
||||
|
||||
mkdir -p /tmp/pgadmin-icon
|
||||
xcrun actool pgAdmin4.icon --compile /tmp/pgadmin-icon \
|
||||
--platform macosx --minimum-deployment-target 10.13 \
|
||||
--app-icon pgAdmin4 --output-partial-info-plist /tmp/pgadmin-icon/partial.plist
|
||||
cp /tmp/pgadmin-icon/Assets.car .
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
1. The version of Python that gets bundled is read from
|
||||
*pkg/python-version.txt*, which every platform's packaging shares so that
|
||||
one commit cannot ship different interpreters on different systems. To
|
||||
bundle a different version, edit that file.
|
||||
*pkg/python-version.txt*, which the Windows packaging reads as well so that
|
||||
one commit cannot ship different interpreters on the two platforms that
|
||||
bundle one. To bundle a different version, edit that file.
|
||||
|
||||
2. If a path different from the default of /usr/local/pgsql for the PostgreSQL
|
||||
installation has been used, set the *PGADMIN_POSTGRES_DIR* environment variable
|
||||
|
||||
@@ -33,14 +33,32 @@ _build_runtime() {
|
||||
|
||||
test -d "${BUILD_ROOT}" || mkdir "${BUILD_ROOT}"
|
||||
# Get a fresh copy of electron
|
||||
# Resolve the electron version from runtime/package.json, NOT from
|
||||
# `npm info electron version`. The latter fetches whatever currently
|
||||
# carries the `latest` dist-tag on the npm registry, which means any
|
||||
# newly published electron release lands in shipped binaries without
|
||||
# review. Keep the build deterministic and pinned.
|
||||
ELECTRON_VERSION=$(sed -nE 's/.*"electron":[[:space:]]*"\^?([0-9.]+)".*/\1/p' "${SOURCE_DIR}/runtime/package.json" | head -1)
|
||||
# Resolve the electron version from runtime/yarn.lock, NOT from the npm
|
||||
# registry and NOT from the range in runtime/package.json. The registry's
|
||||
# `latest` dist-tag lands any newly published electron release in shipped
|
||||
# binaries without review, whilst the package.json range is only a lower
|
||||
# bound, so yarn is free to resolve it to a build other than the one we
|
||||
# ship. The lockfile is the single source of truth, and `yarn info` reports
|
||||
# its resolution without touching the network or needing node_modules.
|
||||
#
|
||||
# It must run inside runtime/ though: outside a yarn project, `yarn info`
|
||||
# silently falls back to querying the registry and still exits 0, so check
|
||||
# the lockfile is present first and validate what comes back.
|
||||
if [ ! -f "${SOURCE_DIR}/runtime/yarn.lock" ]; then
|
||||
echo "ERROR: ${SOURCE_DIR}/runtime/yarn.lock not found; cannot resolve the pinned electron version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ELECTRON_VERSION=$(cd "${SOURCE_DIR}/runtime" && yarn info electron --json | node -e "
|
||||
const lines = require('fs').readFileSync(0, 'utf8').split('\n').filter(Boolean);
|
||||
const pkg = lines.map(l => { try { return JSON.parse(l); } catch (e) { return null; } })
|
||||
.find(o => o && typeof o.value === 'string' && o.value.startsWith('electron@npm:'));
|
||||
const version = (pkg && pkg.children && pkg.children.Version) || '';
|
||||
process.stdout.write(/^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?$/.test(version) ? version : '');
|
||||
")
|
||||
|
||||
if [ -z "${ELECTRON_VERSION}" ]; then
|
||||
echo "ERROR: could not resolve electron version from runtime/package.json" >&2
|
||||
echo "ERROR: could not resolve the pinned electron version from ${SOURCE_DIR}/runtime/yarn.lock" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -105,6 +123,11 @@ _create_python_env() {
|
||||
"${BUNDLE_DIR}/Contents/Frameworks/Python.framework/Versions/Current/bin/python3" -m ensurepip --upgrade || exit 1
|
||||
"${BUNDLE_DIR}/Contents/Frameworks/Python.framework/Versions/Current/bin/pip3" install --no-cache-dir -r "${SOURCE_DIR}/requirements.txt" || exit 1
|
||||
|
||||
# Remove the files in the installed packages that we never use, and the
|
||||
# packaging tools (pip, setuptools and wheel) that are only needed to
|
||||
# build the environment
|
||||
"${BUNDLE_DIR}/Contents/Frameworks/Python.framework/Versions/Current/bin/python3" "${SOURCE_DIR}/pkg/prune_site_packages.py" "${BUNDLE_DIR}/Contents/Frameworks/Python.framework" || exit 1
|
||||
|
||||
# Make sure all the .so's in the Python env have the executable bit set
|
||||
# so they get properly signed later
|
||||
OLD_IFS=${IFS}
|
||||
@@ -299,8 +322,11 @@ _complete_bundle() {
|
||||
# PkgInfo
|
||||
echo APPLPGA4 > "${BUNDLE_DIR}/Contents/PkgInfo"
|
||||
|
||||
# Icon
|
||||
# Icon. Assets.car is compiled from pgAdmin4.icon (see README.md) and
|
||||
# gives macOS 26+ the dark, clear and tinted variants; older releases
|
||||
# fall back to app.icns.
|
||||
cp pgAdmin4.icns "${BUNDLE_DIR}/Contents/Resources/app.icns"
|
||||
cp Assets.car "${BUNDLE_DIR}/Contents/Resources/Assets.car"
|
||||
|
||||
# Rename the executable
|
||||
mv "${BUNDLE_DIR}/Contents/MacOS/Electron" "${BUNDLE_DIR}/Contents/MacOS/${APP_NAME}"
|
||||
@@ -319,7 +345,7 @@ _complete_bundle() {
|
||||
exit 1
|
||||
fi
|
||||
yarn set version "${YARN_VERSION}"
|
||||
yarn install 2>&1
|
||||
yarn install --immutable 2>&1
|
||||
|
||||
# Record the source commit hash before the heavy lint/webpack
|
||||
# steps. `yarn run` needs node_modules so this runs after install,
|
||||
@@ -767,6 +793,74 @@ _codesign_dmg() {
|
||||
"${DMG_NAME}"
|
||||
}
|
||||
|
||||
# The submission's status, or an empty string if Apple could not be reached.
|
||||
# Separate from the waiting below because both need it and because a failure
|
||||
# to ask is not the same thing as an answer.
|
||||
_notarize_status() {
|
||||
local SUBMISSION_ID="$1"
|
||||
|
||||
xcrun notarytool info "${SUBMISSION_ID}" \
|
||||
--team-id "${DEVELOPER_TEAM_ID}" \
|
||||
--apple-id "${DEVELOPER_USER}" \
|
||||
--password "${DEVELOPER_ASP}" 2>&1 | \
|
||||
awk -F ': ' '/status:/ { print $2; }'
|
||||
}
|
||||
|
||||
# Wait for a submission Apple has already accepted for processing.
|
||||
#
|
||||
# notarytool wait polls Apple for as long as the notarisation takes, which is
|
||||
# minutes at best and has been much longer, and it exits non-zero if any one of
|
||||
# those polls fails. A build farm runner losing its route for a few seconds
|
||||
# therefore failed a notarisation that was proceeding perfectly well on Apple's
|
||||
# side, which is what cost the 22nd September snapshot: the DMG had been
|
||||
# submitted, the ID was in hand, and the wait died with "The Internet
|
||||
# connection appears to be offline" twenty six minutes in.
|
||||
#
|
||||
# So a failed wait is retried rather than being taken as the answer. The
|
||||
# submission is not resubmitted, since it is still queued under the same ID and
|
||||
# a second copy would only be a second thing to wait for. Each failure asks for
|
||||
# the status directly, because notarytool wait also exits non-zero when the
|
||||
# submission genuinely finishes as Invalid or Rejected, and retrying that would
|
||||
# be waiting for an answer already given: any terminal status ends the loop and
|
||||
# leaves the caller's own check to decide what it means.
|
||||
_notarize_wait() {
|
||||
local SUBMISSION_ID="$1"
|
||||
local ATTEMPT=1
|
||||
local ATTEMPTS=10
|
||||
local DELAY=60
|
||||
local STATUS
|
||||
|
||||
while [ "${ATTEMPT}" -le "${ATTEMPTS}" ]; do
|
||||
if xcrun notarytool wait "${SUBMISSION_ID}" \
|
||||
--team-id "${DEVELOPER_TEAM_ID}" \
|
||||
--apple-id "${DEVELOPER_USER}" \
|
||||
--password "${DEVELOPER_ASP}"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
STATUS=$(_notarize_status "${SUBMISSION_ID}")
|
||||
case "${STATUS}" in
|
||||
Accepted|Invalid|Rejected)
|
||||
echo "Notarization finished whilst waiting, with status: ${STATUS}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${ATTEMPT}" -eq "${ATTEMPTS}" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Could not wait on submission ${SUBMISSION_ID} (attempt ${ATTEMPT} of ${ATTEMPTS})."
|
||||
echo "The submission is still with Apple; retrying in ${DELAY} seconds."
|
||||
sleep "${DELAY}"
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "Gave up waiting for submission ${SUBMISSION_ID} after ${ATTEMPTS} attempts."
|
||||
echo "Check it by hand with: xcrun notarytool info ${SUBMISSION_ID} ..."
|
||||
return 1
|
||||
}
|
||||
|
||||
_notarize_pkg() {
|
||||
local FILE_NAME="$1"
|
||||
local STAPLE_TARGET="$2"
|
||||
@@ -789,17 +883,10 @@ _notarize_pkg() {
|
||||
echo "Notarization submission ID: ${SUBMISSION_ID}"
|
||||
|
||||
echo "Waiting for Notarization to be completed ..."
|
||||
xcrun notarytool wait "${SUBMISSION_ID}" \
|
||||
--team-id "${DEVELOPER_TEAM_ID}" \
|
||||
--apple-id "${DEVELOPER_USER}" \
|
||||
--password "${DEVELOPER_ASP}"
|
||||
_notarize_wait "${SUBMISSION_ID}"
|
||||
|
||||
# Print status information
|
||||
REQUEST_STATUS=$(xcrun notarytool info "${SUBMISSION_ID}" \
|
||||
--team-id "${DEVELOPER_TEAM_ID}" \
|
||||
--apple-id "${DEVELOPER_USER}" \
|
||||
--password "${DEVELOPER_ASP}" 2>&1 | \
|
||||
awk -F ': ' '/status:/ { print $2; }')
|
||||
REQUEST_STATUS=$(_notarize_status "${SUBMISSION_ID}")
|
||||
|
||||
if [[ "${REQUEST_STATUS}" != "Accepted" ]]; then
|
||||
echo "Notarization failed with status: ${REQUEST_STATUS}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="432" height="446" viewBox="0 0 432.071 445.383" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#336791" d="M402.395,271.23c-50.302,10.376-53.76-6.655-53.76-6.655c53.111-78.808,75.313-178.843,56.153-203.326c-52.27-66.785-142.752-35.2-144.262-34.38l-0.486,0.087c-9.938-2.063-21.06-3.292-33.56-3.496c-22.761-0.373-40.026,5.967-53.127,15.902 c0,0-161.411-66.495-153.904,83.63c1.597,31.938,45.776,241.657,98.471,178.312c19.26-23.163,37.869-42.748,37.869-42.748c9.243,6.14,20.308,9.272,31.908,8.147l0.901-0.765c-0.28,2.876-0.152,5.689,0.361,9.019c-13.575,15.167-9.586,17.83-36.723,23.416 c-27.459,5.659-11.328,15.734-0.796,18.367c12.768,3.193,42.307,7.716,62.266-20.224l-0.796,3.188c5.319,4.26,9.054,27.711,8.428,48.969c-0.626,21.259-1.044,35.854,3.147,47.254c4.191,11.4,8.368,37.05,44.042,29.406c29.809-6.388,45.256-22.942,47.405-50.555 c1.525-19.631,4.976-16.729,5.194-34.28l2.768-8.309c3.192-26.611,0.507-35.196,18.872-31.203l4.463,0.392c13.517,0.615,31.208-2.174,41.591-7c22.358-10.376,35.618-27.7,13.573-23.148z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="432" height="446" viewBox="0 0 432.071 445.383" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="8.3" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="4">
|
||||
<path d="M215.866,286.484c-1.385,49.516,0.348,99.377,5.193,111.495c4.848,12.118,15.223,35.688,50.9,28.045c29.806-6.39,40.651-18.756,45.357-46.051c3.466-20.082,10.148-75.854,11.005-87.281"/>
|
||||
<path d="M173.104,38.256c0,0-161.521-66.016-154.012,84.109c1.597,31.938,45.779,241.664,98.473,178.316c19.256-23.166,36.671-41.335,36.671-41.335"/>
|
||||
<path d="M260.349,26.207c-5.591,1.753,89.848-34.889,144.087,34.417c19.159,24.484-3.043,124.519-56.153,203.329"/>
|
||||
<path style="stroke-linejoin:bevel;" d="M348.282,263.953c0,0,3.461,17.036,53.764,6.653c22.04-4.552,8.776,12.774-13.577,23.155c-18.345,8.514-59.474,10.696-60.146-1.069c-1.729-30.355,21.647-21.133,19.96-28.739c-1.525-6.85-11.979-13.573-18.894-30.338 c-6.037-14.633-82.796-126.849,21.287-110.183c3.813-0.789-27.146-99.002-124.553-100.599c-97.385-1.597-94.19,119.762-94.19,119.762"/>
|
||||
<path d="M188.604,274.334c-13.577,15.166-9.584,17.829-36.723,23.417c-27.459,5.66-11.326,15.733-0.797,18.365c12.768,3.195,42.307,7.718,62.266-20.229c6.078-8.509-0.036-22.086-8.385-25.547c-4.034-1.671-9.428-3.765-16.361,3.994z"/>
|
||||
<path d="M187.715,274.069c-1.368-8.917,2.93-19.528,7.536-31.942c6.922-18.626,22.893-37.255,10.117-96.339c-9.523-44.029-73.396-9.163-73.436-3.193c-0.039,5.968,2.889,30.26-1.067,58.548c-5.162,36.913,23.488,68.132,56.479,64.938"/>
|
||||
<path style="fill:#FFFFFF;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter;" d="M172.517,141.7c-0.288,2.039,3.733,7.48,8.976,8.207c5.234,0.73,9.714-3.522,9.998-5.559c0.284-2.039-3.732-4.285-8.977-5.015c-5.237-0.731-9.719,0.333-9.996,2.367z"/>
|
||||
<path style="fill:#FFFFFF;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter;" d="M331.941,137.543c0.284,2.039-3.732,7.48-8.976,8.207c-5.238,0.73-9.718-3.522-10.005-5.559c-0.277-2.039,3.74-4.285,8.979-5.015c5.239-0.73,9.718,0.333,10.002,2.368z"/>
|
||||
<path d="M350.676,123.432c0.863,15.994-3.445,26.888-3.988,43.914c-0.804,24.748,11.799,53.074-7.191,81.435"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:0.92549,0.94118,0.95686,1.00000",
|
||||
"srgb:0.76078,0.81961,0.87059,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "system-dark"
|
||||
}
|
||||
],
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "Lines.svg",
|
||||
"name" : "Lines",
|
||||
"position" : {
|
||||
"scale" : 1.85,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"glass" : false,
|
||||
"image-name" : "Body.svg",
|
||||
"name" : "Body",
|
||||
"position" : {
|
||||
"scale" : 1.85,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
30
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Slonik",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.25
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"squares" : [
|
||||
"macOS"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# pgbuild dependency lock
|
||||
#
|
||||
# Which build of each dependency .github/actions/install-pgbuild-deps
|
||||
# downloads from pgadmin-org/pgbuild, and what it must hash to. The action
|
||||
# fetches the tag named here and refuses to unpack an archive whose SHA-256
|
||||
# differs, so these lines decide which PostgreSQL, OpenSSL and Kerberos
|
||||
# binaries end up inside an Authenticode signed installer and a notarised
|
||||
# appbundle.
|
||||
#
|
||||
# It matters because pgbuild also publishes rolling tags ending in -latest,
|
||||
# whose assets are replaced in place on every rebuild. Building from those
|
||||
# means signing whatever they happened to point at that morning, write
|
||||
# access to pgbuild is write access to what pgAdmin ships, and no two
|
||||
# builds of the same commit contain the same binaries. Pinning the
|
||||
# per-version tag instead gives a build that can be reproduced and a change
|
||||
# that can be reviewed. pgbuild publishes a .sha256 beside each archive,
|
||||
# which is a convenience for people rather than a source of trust, since
|
||||
# whoever rebuilds the archive rebuilds that too; this file is the only
|
||||
# thing that actually pins anything, which is why the action checks against
|
||||
# it and not against the published checksum.
|
||||
#
|
||||
# Three whitespace separated columns, one dependency per line, with # for a
|
||||
# comment. Kept dull on purpose so that bash, PowerShell and a reviewer can
|
||||
# all read it.
|
||||
#
|
||||
# 1. the dependency, as the action names it: package, platform and
|
||||
# architecture, with the PostgreSQL major where there is one
|
||||
# 2. the pgbuild release tag to download from, which carries the upstream
|
||||
# version, so the second column is where a bump shows up in the diff
|
||||
# 3. the SHA-256 of the archive in that release, in lowercase hex
|
||||
#
|
||||
# A build that fails on a mismatch is telling you that the bytes behind a
|
||||
# pinned tag are not the bytes that were reviewed. A build that merely
|
||||
# warns is telling you pgbuild has published something newer, which is
|
||||
# routine and is not an error: the build carries on with the pinned
|
||||
# version. Either way the fix is to refresh, read the diff and commit it:
|
||||
#
|
||||
# tools/refresh_pgbuild_lock.py <package>
|
||||
#
|
||||
# This file is generated by that script, so edit it through the script
|
||||
# rather than by hand.
|
||||
|
||||
# macOS, arm64
|
||||
krb5-macos-arm64 krb5-1.22.2-macos-arm64 d61ec76f7adc79b915bb88f55bc4755db2edb5f215431aff01959e0fe38214ea
|
||||
lz4-macos-arm64 lz4-1.10.0-macos-arm64 50824e82cf864469b7d5e01d46896db07a48c4b143d7d4edb4bd7355ea5ebe29
|
||||
openssl-macos-arm64 openssl-3.5.8-macos-arm64 e319fda192735b7b4f7b5742388b9b2264c6e94078f79a9dbb856d2cadcb0c4c
|
||||
postgresql-18-macos-arm64 postgresql-18.6-macos-arm64 b21232335b76b3cb828839f98572da4298e19cad4a69b31f7b234d218e149c28
|
||||
zstd-macos-arm64 zstd-1.5.7-macos-arm64 6703acb18b9db260df484828d0e823017742be82c87cdd3d42fde85efdd232ad
|
||||
|
||||
# macOS, x86_64
|
||||
krb5-macos-x86_64 krb5-1.22.2-macos-x86_64 40e123c1a85dba06b1d808cecf6c1e5ac2818fb8a722122400a1ffac9494651f
|
||||
lz4-macos-x86_64 lz4-1.10.0-macos-x86_64 ff1e23e0150533abf0f81c1341e7dab8e7e18dd51158084ae4ac9b847be6df7c
|
||||
openssl-macos-x86_64 openssl-3.5.8-macos-x86_64 e5a947e1ddecd9a75c9c0f8f3b92d7de74958d6a3bbcea99c509fb4900dad431
|
||||
postgresql-18-macos-x86_64 postgresql-18.6-macos-x86_64 b36aa6afe7515806aa9724ae6ceebed214b1fec9c2849609598b19c4ecae893e
|
||||
zstd-macos-x86_64 zstd-1.5.7-macos-x86_64 77634c1dafe07882d63ace5e13e4f59f583b4763e04076611515335fe1168a41
|
||||
|
||||
# Windows, x86_64
|
||||
krb5-windows-x86_64 krb5-1.22.2-windows-x86_64 7557cf16faf8af1ee450ef211ac8f74a513914d722c776a3645d996e7cd08dfc
|
||||
postgresql-18-windows-x86_64 postgresql-18.6-windows-x86_64 4eba1f86561238348a39040809f7b1d08f434ef110c2ffe37e291d63c0407d67
|
||||
@@ -62,7 +62,7 @@ if [ -z "${YARN_VERSION}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
yarn set version "${YARN_VERSION}"
|
||||
yarn install
|
||||
yarn install --immutable
|
||||
yarn run bundle
|
||||
|
||||
# Copy the commit_hash file, it doesn't show up in git ls-files
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
##########################################################################
|
||||
#
|
||||
# pgAdmin 4 - PostgreSQL Tools
|
||||
#
|
||||
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
||||
# This software is released under the PostgreSQL Licence
|
||||
#
|
||||
##########################################################################
|
||||
|
||||
"""Remove files that pgAdmin never uses from an installed Python
|
||||
environment, to reduce the size of the packages.
|
||||
|
||||
Usage: python3 prune_site_packages.py <directory>
|
||||
|
||||
The directory is searched recursively, so it may be a venv, a Python
|
||||
framework or a site-packages directory. The build fails if a file that is
|
||||
kept cannot be found, rather than shipping a package that cannot work.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
CONFIG_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..',
|
||||
'web', 'config.py')
|
||||
|
||||
|
||||
def languages():
|
||||
"""Return the language codes of the translations pgAdmin has, from the
|
||||
keys of LANGUAGES in web/config.py, without the territory or script
|
||||
(e.g. 'zh' for 'zh_Hans_CN'). The file is parsed rather than imported,
|
||||
as importing it needs pgAdmin's own modules."""
|
||||
with open(CONFIG_PY, encoding='utf-8') as f:
|
||||
tree = ast.parse(f.read(), CONFIG_PY)
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and \
|
||||
any(isinstance(t, ast.Name) and t.id == 'LANGUAGES'
|
||||
for t in node.targets):
|
||||
return sorted({code.split('_')[0]
|
||||
for code in ast.literal_eval(node.value)})
|
||||
|
||||
sys.exit('ERROR: LANGUAGES was not found in {}'.format(CONFIG_PY))
|
||||
|
||||
|
||||
# Each entry maps a directory to the files in it that may be removed, and
|
||||
# the patterns for the ones to keep. Every keep pattern must match at least
|
||||
# one file.
|
||||
PRUNE = {
|
||||
# google-api-python-client bundles the discovery document of every
|
||||
# Google API (600 or so files, over 100MB), and discovery.build() reads
|
||||
# them from there rather than fetching them. pgAdmin only builds the
|
||||
# services below; see the *_api_version attributes in
|
||||
# web/pgadmin/misc/cloud/google/__init__.py and the discovery.build()
|
||||
# calls in web/pgacloud/providers/google.py, which must be kept in step
|
||||
# with this list.
|
||||
os.path.join('googleapiclient', 'discovery_cache', 'documents'): (
|
||||
'*.json', [
|
||||
'cloudresourcemanager.v1.json',
|
||||
'compute.v1.json',
|
||||
'sqladmin.v1.json',
|
||||
'sqladmin.v1beta4.json',
|
||||
]),
|
||||
|
||||
# Babel carries CLDR data for over 1,000 locales (about 29MB), and only
|
||||
# loads the user's locale and its parents, down to root. Keeping every
|
||||
# territory and script variant of each language covers the parents.
|
||||
os.path.join('babel', 'locale-data'): (
|
||||
'*.dat',
|
||||
['root.dat'] +
|
||||
['{}{}.dat'.format(lang, suffix)
|
||||
for lang in languages() for suffix in ('', '_*')]),
|
||||
}
|
||||
|
||||
# Packaging tools that are needed to build the environment but not to run
|
||||
# pgAdmin. Each is removed from any site-packages directory it is found in.
|
||||
DISTRIBUTIONS = ['pip', 'setuptools', 'wheel']
|
||||
|
||||
# A module-level import of pkg_resources, which setuptools provides. Imports
|
||||
# inside a function or a try block are indented, and are either lazy or
|
||||
# guarded against it being missing.
|
||||
PKG_RESOURCES_IMPORT = re.compile(r'^(?:import|from)[ \t]+pkg_resources\b',
|
||||
re.MULTILINE)
|
||||
|
||||
|
||||
def pkg_resources_users(site_packages):
|
||||
"""Return the modules in site_packages, other than the packaging tools'
|
||||
own and tests, that import pkg_resources when they are loaded. On Python
|
||||
3.9, passlib 1.7.4 does, so setuptools must be kept there (#9829)."""
|
||||
own = {'pip', 'setuptools', 'pkg_resources', '_distutils_hack', 'wheel'}
|
||||
users = []
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(site_packages):
|
||||
if dirpath == site_packages:
|
||||
dirnames[:] = [d for d in dirnames if d not in own]
|
||||
dirnames[:] = [d for d in dirnames if d not in ('test', 'tests')]
|
||||
for filename in fnmatch.filter(filenames, '*.py'):
|
||||
path = os.path.join(dirpath, filename)
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
if PKG_RESOURCES_IMPORT.search(f.read()):
|
||||
users.append(os.path.relpath(path, site_packages))
|
||||
|
||||
return sorted(users)
|
||||
|
||||
|
||||
def prune_dir(dirpath, removable, keep):
|
||||
"""Remove the files in dirpath matching removable but none of the keep
|
||||
patterns, and return the number of bytes freed."""
|
||||
filenames = os.listdir(dirpath)
|
||||
|
||||
missing = [pattern for pattern in keep
|
||||
if not fnmatch.filter(filenames, pattern)]
|
||||
if missing:
|
||||
sys.exit('ERROR: {} is missing from {}'.format(
|
||||
', '.join(missing), dirpath))
|
||||
|
||||
saved = 0
|
||||
for filename in fnmatch.filter(filenames, removable):
|
||||
if any(fnmatch.fnmatch(filename, pattern) for pattern in keep):
|
||||
continue
|
||||
path = os.path.join(dirpath, filename)
|
||||
saved += os.path.getsize(path)
|
||||
os.remove(path)
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def _tree_size(path):
|
||||
return sum(os.path.getsize(os.path.join(dirpath, filename))
|
||||
for dirpath, _, filenames in os.walk(path)
|
||||
for filename in filenames)
|
||||
|
||||
|
||||
def uninstall(site_packages, dist_info, root):
|
||||
"""Remove the files listed in a distribution's RECORD, as pip uninstall
|
||||
would, and return the number of bytes freed. Files outside root, such as
|
||||
the scripts in a Windows venv when only site-packages is being pruned,
|
||||
are left alone."""
|
||||
root = os.path.abspath(root)
|
||||
saved = 0
|
||||
packages = set()
|
||||
|
||||
# Read the whole of RECORD first, as it lists itself, and Windows cannot
|
||||
# remove a file that is still open.
|
||||
with open(os.path.join(site_packages, dist_info, 'RECORD'),
|
||||
encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
relpath = line.rsplit(',', 2)[0]
|
||||
if not relpath:
|
||||
continue
|
||||
path = os.path.abspath(os.path.join(site_packages, relpath))
|
||||
if os.path.commonpath([root, path]) != root:
|
||||
continue
|
||||
if os.sep in os.path.normpath(relpath) and \
|
||||
not relpath.startswith('..'):
|
||||
packages.add(os.path.normpath(relpath).split(os.sep)[0])
|
||||
if os.path.isfile(path) or os.path.islink(path):
|
||||
saved += os.path.getsize(path)
|
||||
os.remove(path)
|
||||
|
||||
# Remove the package directories too, including any bytecode compiled
|
||||
# after installation, which RECORD does not list.
|
||||
for package in packages:
|
||||
path = os.path.join(site_packages, package)
|
||||
if os.path.isdir(path):
|
||||
saved += _tree_size(path)
|
||||
shutil.rmtree(path)
|
||||
|
||||
return saved
|
||||
|
||||
|
||||
def prune(root):
|
||||
found = set()
|
||||
saved = 0
|
||||
removed = []
|
||||
|
||||
for dirpath, dirnames, _ in os.walk(root):
|
||||
for subdir, (removable, keep) in PRUNE.items():
|
||||
if dirpath.endswith(os.sep + subdir):
|
||||
found.add(subdir)
|
||||
saved += prune_dir(dirpath, removable, keep)
|
||||
|
||||
if os.path.basename(dirpath) == 'site-packages':
|
||||
users = pkg_resources_users(dirpath)
|
||||
for name in DISTRIBUTIONS:
|
||||
if name == 'setuptools' and users:
|
||||
print('Keeping setuptools, as pkg_resources is imported '
|
||||
'by {}'.format(', '.join(users)))
|
||||
continue
|
||||
for dist_info in fnmatch.filter(dirnames,
|
||||
name + '-*.dist-info'):
|
||||
saved += uninstall(dirpath, dist_info, root)
|
||||
removed.append(dist_info[:-len('.dist-info')])
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if os.path.isdir(os.path.join(dirpath, d))]
|
||||
|
||||
for subdir in set(PRUNE) - found:
|
||||
sys.exit('ERROR: {} was not found under {}'.format(subdir, root))
|
||||
|
||||
if removed:
|
||||
print('Removed {}'.format(', '.join(sorted(removed))))
|
||||
print('Pruned {:.1f}MB of unused files from {}'.format(
|
||||
saved / 1024 / 1024, root))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 2 or not os.path.isdir(sys.argv[1]):
|
||||
sys.exit('Usage: {} <directory>'.format(sys.argv[0]))
|
||||
|
||||
prune(sys.argv[1])
|
||||
@@ -0,0 +1,3 @@
|
||||
Support: pgadmin-support@lists.postgresql.org
|
||||
Website: https://www.pgadmin.org/
|
||||
Tracker: https://github.com/pgadmin-org/pgadmin4/issues
|
||||
@@ -28,17 +28,46 @@ which verbs exist.
|
||||
| `rebuild-apt-repo.sh` | Index and sign one APT tree |
|
||||
| `rebuild-yum-repo.sh` | Index and sign one YUM tree, and make the EL compatibility links |
|
||||
| `install-repo-readme.py` | Write the README at the top of an APT or YUM tree |
|
||||
| `sync-ftp-to-s3.py` | Copy the download tree to the archive bucket, build its indexes and invalidate the CDN |
|
||||
| `purge-pgadmin-releases.sh` | Delete superseded releases from the download tree and from both repositories |
|
||||
| `README.apt.in`, `README.yum.in` | The prose those READMEs are built from |
|
||||
| `aptftp.conf` | `apt-ftparchive` settings: Origin, Label, Suite, Description |
|
||||
| `CURRENT_MAINTAINER` | The marker the PostgreSQL mirror network expects in each published directory |
|
||||
| `pga-publish` | The forced-command wrapper |
|
||||
| `authorized_keys.procyon`, `authorized_keys.paxsor` | Annotated templates for the two servers |
|
||||
| `authorized_keys.staging`, `authorized_keys.download` | Annotated templates |
|
||||
| `selftest.sh` | Exercises the wrapper's parser without touching anything |
|
||||
|
||||
The three scripts work standalone. Rebuilding a repository by hand after
|
||||
All five scripts work standalone. Rebuilding a repository by hand after
|
||||
purging old releases is what `rebuild-apt-repo.sh bookworm` has always been
|
||||
for, and that has not changed; the wrapper calls the same script rather than
|
||||
carrying its own copy of the logic.
|
||||
|
||||
`sync-ftp-to-s3.py` and `purge-pgadmin-releases.sh` used to live in the
|
||||
`pgadmin-org/pgaweb` checkout under `tools/`, which was never really where
|
||||
they belonged: neither of them touches Django, the site database or Varnish,
|
||||
and both work on the download tree at `/var/ftp/pgadmin4`, so they are part of
|
||||
publishing rather than part of the website. They have moved here, and the
|
||||
wrapper's `sync-s3` verb now runs the copy installed from this directory
|
||||
rather than the one in the website checkout.
|
||||
|
||||
That move has an order to it. The wrapper and the copy of `sync-ftp-to-s3.py`
|
||||
that it names must both be installed on the download server before the pgaweb
|
||||
side deletes its own copy, because until the new wrapper is in place the one
|
||||
running there is still looking for the script under `/var/www/pgaweb/tools`.
|
||||
The other three verbs that reach into that directory, `create-release`,
|
||||
`load-docs` and `purge-cache`, are genuinely website operations and stay
|
||||
exactly where they are.
|
||||
|
||||
`purge-pgadmin-releases.sh` has no verb and is not meant to get one. It is run
|
||||
by hand, on the download server, by somebody who has decided that a release is
|
||||
old enough to go; it deletes things, and the whole point of the wrapper is
|
||||
that a compromised runner cannot delete anything that is already published.
|
||||
Its default is a dry run, so the shape of the work is to read what it proposes
|
||||
and then re-run it with `--live`, which is a conversation rather than a
|
||||
command, and nothing a workflow could sensibly have. Rebuilding the
|
||||
repositories afterwards is the standalone use of `rebuild-apt-repo.sh` and
|
||||
`rebuild-yum-repo.sh` described above.
|
||||
|
||||
## Installing
|
||||
|
||||
On both servers, as root:
|
||||
@@ -46,8 +75,10 @@ On both servers, as root:
|
||||
```sh
|
||||
install -d -m 755 /usr/local/lib/pga-publish
|
||||
install -m 755 rebuild-apt-repo.sh rebuild-yum-repo.sh install-repo-readme.py \
|
||||
sync-ftp-to-s3.py purge-pgadmin-releases.sh \
|
||||
/usr/local/lib/pga-publish/
|
||||
install -m 644 aptftp.conf README.apt.in README.yum.in CURRENT_MAINTAINER \
|
||||
/usr/local/lib/pga-publish/
|
||||
install -m 644 aptftp.conf README.apt.in README.yum.in /usr/local/lib/pga-publish/
|
||||
install -m 755 pga-publish /usr/local/sbin/pga-publish
|
||||
```
|
||||
|
||||
@@ -67,29 +98,38 @@ configuration, so it lives here rather than in the source tree:
|
||||
echo '<distribution id>' > /etc/pga-publish.cloudfront
|
||||
```
|
||||
|
||||
Also on the download server, move `aptftp.conf` out of `~pgaupload`: it is an
|
||||
input to what gets indexed and therefore to what gets signed, and nothing
|
||||
needs the publishing account to be able to write it.
|
||||
And the staging server it pulls from during a release, which is site
|
||||
configuration rather than code, and is deliberately not something the caller
|
||||
can name:
|
||||
|
||||
```sh
|
||||
echo '<staging server>' > /etc/pga-publish.pull-host
|
||||
```
|
||||
|
||||
`aptftp.conf` is installed with the scripts above rather than kept in
|
||||
`~pgaupload`, which is where it lived before this: it is an input to what gets
|
||||
indexed and therefore to what gets signed, and nothing needs the publishing
|
||||
account to be able to write it.
|
||||
|
||||
## Access
|
||||
|
||||
Three key pairs. `roadie-publish` and `roadie-upload` belong to the runner;
|
||||
`paxsor-pull` belongs to the download server, which uses it to fetch staged
|
||||
content from the staging server during publication. Its private half must be at
|
||||
`~pgaupload/.ssh/id_ed25519_paxsor_pull` on the download server, which is
|
||||
where `pga-publish` looks for it; the name in the key management system is
|
||||
only a label, and the path is what matters.
|
||||
Three key pairs. A publishing key and an upload key belong to the runner; a
|
||||
pull key belongs to the download server, which uses it to fetch staged content
|
||||
from the staging server during publication. The pull key's private half must be
|
||||
at `~pgaupload/.ssh/pga-pull` on the download server, which is where
|
||||
`pga-publish` looks for it; the name in the key management system is only a
|
||||
label, and the path is what matters.
|
||||
|
||||
`authorized_keys.procyon` and `authorized_keys.paxsor` are annotated templates:
|
||||
substitute the addresses and the public keys, and read the annotations before
|
||||
changing an option, since each is there for a reason that is easier to write
|
||||
down than to rediscover.
|
||||
`authorized_keys.staging` and `authorized_keys.download` are annotated
|
||||
templates: substitute the public keys, and read the annotations before changing
|
||||
an option, since each is there for a reason that is easier to write down than
|
||||
to rediscover.
|
||||
|
||||
The private keys live on the runner's filesystem, at `~/.ssh/roadie-publish`
|
||||
and `~/.ssh/roadie-upload`, and the workflow references them by path rather
|
||||
than carrying copies in GitHub's secret store: they are already on the machine,
|
||||
and a second copy would mean a second place to rotate and a second place to
|
||||
leak from. The runner also needs the two servers in its `~/.ssh/known_hosts`,
|
||||
The runner's two private keys live on its own filesystem, and the workflows
|
||||
reference them by path, taken from the `PUBLISH_SSH_KEY` and `UPLOAD_SSH_KEY`
|
||||
repository variables, rather than carrying copies in GitHub's secret store:
|
||||
they are already on the machine, and a second copy would mean a second place to
|
||||
rotate and a second place to leak from. The runner also needs the two servers in its `~/.ssh/known_hosts`,
|
||||
since host key checking is on and there is nobody to answer a prompt.
|
||||
|
||||
The trade-off is that the boundary moves. An environment secret is gated by a
|
||||
@@ -105,10 +145,47 @@ write-only, with deletion and symlink-following refused. `restrict` turns off
|
||||
everything optional, PTY allocation included, and keeps doing so as OpenSSH
|
||||
grows new options.
|
||||
|
||||
## What the wrapper will not do, however it is asked
|
||||
|
||||
Neither key can give you a shell, but a key meant only to add something can
|
||||
still do damage if the verbs behind it will quietly change something that is
|
||||
already published. Three of them will not.
|
||||
|
||||
**Publication is single-shot, over the whole promotion rather than over its
|
||||
first verb.** `release-create` has always refused to make a `v<VERSION>` that
|
||||
exists, but `release-fetch` used to move a staging build's files over the
|
||||
contents of a release published months ago, and `autoupdate-publish` and
|
||||
`sync-s3` would then carry the replacements to the auto-updater and into the
|
||||
archive bucket, which has no `--delete` and so no undo. It now refuses before
|
||||
moving anything if the release directory holds more than the maintainer
|
||||
markers `release-create` left, and refuses each individual file whose
|
||||
destination already exists. A release that needs correcting gets a new
|
||||
version, never a repair applied to the old one.
|
||||
|
||||
**A live package is never replaced.** `packages-fetch` pulls with
|
||||
`--ignore-existing`, so a staging build carrying a filename that is already
|
||||
published leaves the published bytes where they are rather than handing the
|
||||
rebuild something else to sign, and it names what it skipped rather than
|
||||
passing over it: during a genuine promotion, a collision means two builds have
|
||||
produced one filename and somebody needs to know which of them the mirrors
|
||||
have.
|
||||
|
||||
**Snapshot retention is counted in days, not in directories.** Keeping the
|
||||
newest five directories sounds equivalent, and is not, because a directory is
|
||||
something either key can create: `snapshot-create` makes one with the
|
||||
publishing key, and an upload under a name of its own choosing makes one with
|
||||
the upload key, since rrsync confines where a path lands but has nothing to
|
||||
say about what it is called. Five directories dated today with high suffixes
|
||||
filled the keep set and every real snapshot fell off the end of it. Keeping
|
||||
the newest five *dates* cannot be steered that way, because the newest date
|
||||
anybody can produce is today and today is kept however many directories carry
|
||||
it. The price, and it is the intended one rather than an accident, is that a
|
||||
day which built four times keeps all four.
|
||||
|
||||
## Checking it
|
||||
|
||||
```sh
|
||||
./selftest.sh # 56 cases, touches nothing
|
||||
./selftest.sh # 70 cases, touches nothing
|
||||
pga-publish --dry-run --role download rebuild-apt bookworm
|
||||
```
|
||||
|
||||
@@ -123,6 +200,19 @@ A workflow uploads unsigned artefacts and asks for `stage-sign`, so a
|
||||
compromised run can ask for a signature over something it has just uploaded,
|
||||
but never holds the key, signs out of band, or signs with a different one.
|
||||
|
||||
Be clear about how wide that is, because it is wider than "the packages it
|
||||
just built". The signing verbs select by file extension across the tree, so
|
||||
anything ending `.tar.gz`, `.whl`, `.pdf` or `.epub` gets a detached armoured
|
||||
signature, and every `.rpm` under `yum/` is re-signed. The content is
|
||||
arbitrary, and a detached signature carries no context tying it to pgAdmin, so
|
||||
a signature obtained this way can be presented anywhere alongside the file it
|
||||
covers. Selecting by name instead would not change this: an attacker who can
|
||||
write to the tree can name a file whatever a filter expects. What actually
|
||||
bounds it is where the upload key can write, which on the download server is
|
||||
the snapshots tree alone, and on the staging server a build directory that
|
||||
nothing publishes without a promotion. Treat write access to either as
|
||||
equivalent to a signing oracle, and rotate the key if one is suspected.
|
||||
|
||||
The exceptions are the macOS notarisation and the Windows Authenticode
|
||||
signature, which happen on the runners because the Apple credentials and the
|
||||
Certum token cannot follow the GPG key onto these machines.
|
||||
@@ -0,0 +1,67 @@
|
||||
# ~pgaupload/.ssh/authorized_keys on the download server (ftp.pgadmin.org)
|
||||
#
|
||||
# The same two keys as on the staging server, with the same options and for
|
||||
# the same reasons; read the annotations in authorized_keys.staging first,
|
||||
# since they are not repeated here. What differs is the blast radius, because
|
||||
# this host serves the packages that end users install, and the yum and apt
|
||||
# signing keys live here.
|
||||
#
|
||||
# Placeholders in angle brackets must be substituted at install time:
|
||||
#
|
||||
# <...KEY...> the public key, one line, no line breaks
|
||||
#
|
||||
# Note that these are the same two key pairs as on the staging server, not
|
||||
# four. One publishing key and one upload key, each trusted by both hosts, each
|
||||
# confined
|
||||
# differently on each host by the options below. Two more key pairs would mean
|
||||
# two more things to rotate and no reduction in what a compromised runner can
|
||||
# reach, since a compromised runner has both of them anyway.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 1: publishing. Verbs only.
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a holder of this key can do here: create a v<VERSION> directory, which
|
||||
# it cannot do twice, or a snapshot directory, which is deliberately
|
||||
# repeatable; pull the matching staging content from the staging server;
|
||||
# rebuild and sign the apt and yum metadata for an allowlisted distribution;
|
||||
# and run the four existing publication scripts with arguments
|
||||
# that have been validated or allowlisted first.
|
||||
#
|
||||
# What it cannot do, and this is the point of the exercise: choose something
|
||||
# to delete, name a file, name a path, name a host, name a signing key, choose
|
||||
# which binary runs, or supply a single character that reaches a shell.
|
||||
#
|
||||
# Nor can it change anything already published, which is a separate claim and
|
||||
# worth stating separately, because the verbs that add content are the ones
|
||||
# that would otherwise overwrite it. release-fetch refuses to run at all if
|
||||
# v<VERSION> holds anything beyond the markers release-create left, and
|
||||
# refuses each file whose destination exists, so a promotion is single-shot
|
||||
# end to end rather than only at its first verb; packages-fetch pulls with
|
||||
# --ignore-existing, so a package already live keeps the bytes its signed
|
||||
# metadata describes and the mirrors already hold. A release that needs
|
||||
# correcting gets a new version.
|
||||
restrict,command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@runner
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 2: upload. Bytes only, and only into the snapshots tree.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots are built straight onto this server rather than staged elsewhere
|
||||
# and promoted, so unlike the release flow they do need an upload path here.
|
||||
#
|
||||
# The confinement root is the snapshots tree itself, which is the right place
|
||||
# on two counts. It is where snapshots belong, and it is the one part of the
|
||||
# download tree that sync-ftp-to-s3.py excludes, so nothing written through
|
||||
# this key can reach the archive bucket or the PostgreSQL mirrors. A key
|
||||
# confined anywhere else under /var/ftp would have both of those as a side
|
||||
# effect of a single bad upload.
|
||||
#
|
||||
# Retention is not this key's business either, and it cannot be made to be.
|
||||
# -no-del means this key cannot remove a snapshot even if asked to, and
|
||||
# snapshot-purge keeps the newest SNAPSHOT_KEEP dates rather than the newest
|
||||
# that many directories, so an upload cannot evict a real snapshot by arriving
|
||||
# under today's date with a high suffix. rrsync confines where an upload lands
|
||||
# but has nothing to say about what it is called, which is precisely why
|
||||
# retention must not turn on names the client chose.
|
||||
#
|
||||
# SITE: check `command -v rrsync`; the path moved between rsync packages.
|
||||
restrict,command="/usr/bin/rrsync -wo -no-del -munge /var/ftp/pgadmin4/snapshots" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@runner
|
||||
@@ -1,52 +0,0 @@
|
||||
# ~pgaupload/.ssh/authorized_keys on paxsor (ftp.pgadmin.org)
|
||||
#
|
||||
# The same two keys as on procyon, with the same options and for the same
|
||||
# reasons; read the annotations in authorized_keys.procyon first, since they
|
||||
# are not repeated here. What differs is the blast radius, because this host
|
||||
# serves the packages that end users install, and the yum and apt signing keys
|
||||
# live here.
|
||||
#
|
||||
# Placeholders in angle brackets must be substituted at install time:
|
||||
#
|
||||
# <ROADIE-IPV4>, <ROADIE-IPV6> the runner's addresses
|
||||
# <...KEY...> the public key, one line, no line breaks
|
||||
#
|
||||
# Note that these are the same two key pairs as on procyon, not four. One
|
||||
# publishing key and one upload key, each trusted by both hosts, each confined
|
||||
# differently on each host by the options below. Two more key pairs would mean
|
||||
# two more things to rotate and no reduction in what a compromised runner can
|
||||
# reach, since a compromised runner has both of them anyway.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 1: publishing. Verbs only.
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a holder of this key can do on paxsor: create a v<VERSION> or snapshot
|
||||
# directory (refusing if it already exists), pull the matching staging content
|
||||
# from procyon, rebuild and sign the apt and yum metadata for an allowlisted
|
||||
# distribution, and run the four existing publication scripts with arguments
|
||||
# that have been validated or allowlisted first.
|
||||
#
|
||||
# What it cannot do, and this is the point of the exercise: delete anything,
|
||||
# name a file, name a path, name a host, name a signing key, choose which
|
||||
# binary runs, or supply a single character that reaches a shell.
|
||||
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@roadie
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 2: upload. Bytes only, and only into the snapshots tree.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots are built straight onto this server rather than staged on procyon
|
||||
# and promoted, so unlike the release flow they do need an upload path here.
|
||||
#
|
||||
# The confinement root is the snapshots tree itself, which is the right place
|
||||
# on two counts. It is where snapshots belong, and it is the one part of the
|
||||
# download tree that sync-ftp-to-s3.py excludes, so nothing written through
|
||||
# this key can reach the archive bucket or the PostgreSQL mirrors. A key
|
||||
# confined anywhere else under /var/ftp would have both of those as a side
|
||||
# effect of a single bad upload.
|
||||
#
|
||||
# Retention is not this key's business either: the wrapper's snapshot-purge
|
||||
# verb enforces it, and -no-del means this key cannot remove a snapshot even
|
||||
# if asked to.
|
||||
#
|
||||
# SITE: check `command -v rrsync`; the path moved between rsync packages.
|
||||
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/bin/rrsync -wo -no-del -munge /var/ftp/pgadmin4/snapshots" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@roadie
|
||||
@@ -1,15 +1,10 @@
|
||||
# ~pgaupload/.ssh/authorized_keys on procyon (developer.pgadmin.org)
|
||||
# ~pgaupload/.ssh/authorized_keys on the staging server (developer.pgadmin.org)
|
||||
#
|
||||
# Two keys, two jobs, two blast radii. Neither of them can give you a shell.
|
||||
#
|
||||
# Placeholders in angle brackets must be substituted at install time. Do not
|
||||
# commit the substituted file to a public repository: the addresses are not
|
||||
# secret, but publishing the exact shape of the firewall is free reconnaissance
|
||||
# for somebody who has not earned it.
|
||||
# Placeholders in angle brackets must be substituted at install time:
|
||||
#
|
||||
# <ROADIE-IPV4>, <ROADIE-IPV6> the runner's addresses
|
||||
# <PAXSOR-IPV4> the download server's address
|
||||
# <...KEY...> the public key, one line, no line breaks
|
||||
# <...KEY...> the public key, one line, no line breaks
|
||||
#
|
||||
# Options used, and why:
|
||||
#
|
||||
@@ -24,10 +19,16 @@
|
||||
# ask for a terminal, and a terminal is an interactive channel
|
||||
# into whatever the forced command turns out to be.
|
||||
#
|
||||
# from= Defence in depth behind the firewall, not instead of it. The
|
||||
# firewall can be misconfigured during maintenance; this line
|
||||
# is in the same file as the key it constrains, so the two are
|
||||
# reviewed together and rotated together.
|
||||
# There is deliberately no from= here. Restricting a key to
|
||||
# the addresses it should be used from would be worth having,
|
||||
# but these files are generated rather than hand written and
|
||||
# that option is not among the ones we can set, so the whole
|
||||
# of the containment rests on restrict and command= instead.
|
||||
# That is the part which decides what a key can do; what a
|
||||
# from= would have added is a second answer to where it can be
|
||||
# used from, which is a loss worth knowing about rather than a
|
||||
# hole: a stolen key still gets one verb, or one rsync
|
||||
# confined to one directory, and no shell either way.
|
||||
#
|
||||
# command= The forced command. Whatever the client asked to run is put
|
||||
# in SSH_ORIGINAL_COMMAND and this runs instead. The wrapper
|
||||
@@ -40,11 +41,11 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 1: publishing. Verbs only. No file content passes through this key.
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a holder of this key can do on procyon: create a staging directory from
|
||||
# What a holder of this key can do here: create a staging directory from
|
||||
# a fixed template, list staging directories, and rebuild and sign the apt and
|
||||
# yum metadata inside one. That is the entire capability set; it is enumerated
|
||||
# in the VERBS table in pga-publish, on one screen, on purpose.
|
||||
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@roadie
|
||||
restrict,command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@runner
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 2: upload. Bytes only. No verbs, no metadata, no signing.
|
||||
@@ -70,20 +71,23 @@ restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/local/sbin/pga-publish
|
||||
# The trailing path is the root the client is confined to. Every path the
|
||||
# client names is resolved relative to it, and rrsync refuses anything that
|
||||
# climbs out, so the client never sees an absolute path at all.
|
||||
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/bin/rrsync -wo -no-del -munge /var/www/html/builds" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@roadie
|
||||
restrict,command="/usr/bin/rrsync -wo -no-del -munge /var/www/html/builds" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@runner
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key 3: the download server's read-only pull.
|
||||
# ---------------------------------------------------------------------------
|
||||
# paxsor fetches staging content from procyon during publication, so it holds a
|
||||
# The download server fetches staging content from here during publication, so
|
||||
# it holds a
|
||||
# key here. It is read-only and confined to the same staging root, which is a
|
||||
# tree that developer.pgadmin.org already serves over plain HTTP; this edge
|
||||
# therefore grants paxsor nothing it could not have fetched with curl, and it
|
||||
# therefore grants that server nothing it could not have fetched with curl,
|
||||
# and it
|
||||
# is worth saying so explicitly in review, because "the download server can SSH
|
||||
# to the staging server" sounds worse than it is.
|
||||
#
|
||||
# If that reasoning is ever rejected, the alternative is for roadie to pull the
|
||||
# If that reasoning is ever rejected, the alternative is for the runner to pull
|
||||
# the
|
||||
# content down and push it up again over its own two keys, at the cost of
|
||||
# moving every byte twice. The wrapper would lose release-fetch,
|
||||
# packages-fetch and snapshot-fetch, and gain nothing else.
|
||||
restrict,from="<PAXSOR-IPV4>",command="/usr/bin/rrsync -ro /var/www/html/builds" ssh-ed25519 <...PULL-KEY...> pga-pull@paxsor
|
||||
restrict,command="/usr/bin/rrsync -ro /var/www/html/builds" ssh-ed25519 <...PULL-KEY...> pga-pull@download
|
||||
@@ -101,7 +101,8 @@ def archive_keys(object_list=None):
|
||||
def parse_apt(key):
|
||||
"""pgadmin4/apt/<codename>/dists/pgadmin4/main/binary-<arch>/<file>"""
|
||||
parts = key.split("/")
|
||||
if len(parts) != 8 or parts[1] != "apt" or not parts[6].startswith("binary-"):
|
||||
if (len(parts) != 8 or parts[1] != "apt" or
|
||||
not parts[6].startswith("binary-")):
|
||||
return None
|
||||
match = DEB.match(parts[7])
|
||||
if not match or match.group("package") not in VERSIONED_PACKAGES:
|
||||
@@ -133,7 +134,8 @@ def collect(keys, kind):
|
||||
if not parsed:
|
||||
continue
|
||||
platform, arch, version = parsed
|
||||
entry = found.setdefault(platform, {"arches": set(), "versions": set()})
|
||||
entry = found.setdefault(platform,
|
||||
{"arches": set(), "versions": set()})
|
||||
entry["arches"].add(arch)
|
||||
entry["versions"].add(upstream_version(version))
|
||||
return found
|
||||
@@ -193,8 +195,8 @@ def render_table(headings, rows):
|
||||
else text.ljust(width)))
|
||||
return "|" + "|".join(out) + "|"
|
||||
|
||||
return "\n".join([rule, line(headings, False), rule]
|
||||
+ [line(r, True) for r in rows] + [rule])
|
||||
return "\n".join([rule, line(headings, False), rule] +
|
||||
[line(r, True) for r in rows] + [rule])
|
||||
|
||||
|
||||
def main():
|
||||
@@ -204,7 +206,8 @@ def main():
|
||||
parser.add_argument("-r", "--root", default="/var/ftp/pgadmin4",
|
||||
help="the tree holding apt/ and yum/")
|
||||
parser.add_argument("-u", "--base-url",
|
||||
default="https://ftp.postgresql.org/pub/pgadmin/pgadmin4",
|
||||
default="https://ftp.postgresql.org/pub/pgadmin"
|
||||
"/pgadmin4",
|
||||
help="what the instructions tell users to fetch from")
|
||||
parser.add_argument("--no-archive", action="store_true",
|
||||
help="list only what this tree holds, with no release "
|
||||
@@ -225,7 +228,8 @@ def main():
|
||||
if args.no_archive:
|
||||
supported = render_table(
|
||||
["Platform", "Architecture"],
|
||||
[[p, " ".join(sorted(present[p]))] for p in sorted(present, key=platform_key)])
|
||||
[[p, " ".join(sorted(present[p]))]
|
||||
for p in sorted(present, key=platform_key)])
|
||||
else:
|
||||
history = collect(archive_keys(args.object_list), args.kind)
|
||||
for platform in present:
|
||||
@@ -252,7 +256,8 @@ def main():
|
||||
# A snapshot or pre-release tree has no history to show, so the whole
|
||||
# section goes rather than being left saying "(none)".
|
||||
if args.no_archive:
|
||||
text = re.sub(r"@ARCHIVE_SECTION_START@\n.*?@ARCHIVE_SECTION_END@\n\n?",
|
||||
text = re.sub(r"@ARCHIVE_SECTION_START@\n"
|
||||
r".*?@ARCHIVE_SECTION_END@\n\n?",
|
||||
"", text, flags=re.S)
|
||||
else:
|
||||
text = text.replace("@ARCHIVE_SECTION_START@\n", "")
|
||||
@@ -267,8 +272,8 @@ def main():
|
||||
for name, placeholder in (("fedora", "@FEDORA_REPO_RPM@"),
|
||||
("redhat", "@REDHAT_REPO_RPM@")):
|
||||
matches = sorted(f for f in os.listdir(tree)
|
||||
if f.startswith("pgadmin4-%s-repo-" % name)
|
||||
and f.endswith(".noarch.rpm"))
|
||||
if f.startswith("pgadmin4-%s-repo-" % name) and
|
||||
f.endswith(".noarch.rpm"))
|
||||
if not matches:
|
||||
sys.exit("Could not find the %s repository RPM in %s"
|
||||
% (name, tree))
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
# WHY THIS FILE EXISTS, AND WHY IT IS SHAPED THE WAY IT IS
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The GitHub Actions runner (roadie) holds an SSH private key that can reach
|
||||
# procyon and paxsor. The GPG signing keys, the live download tree and the web
|
||||
# The GitHub Actions runner holds an SSH private key that can reach
|
||||
# both servers. The GPG signing keys, the live download tree and the web
|
||||
# root all live on those two servers, so a shell on either of them is, for
|
||||
# practical purposes, the ability to ship a signed package of somebody else's
|
||||
# choosing to every pgAdmin user. The runner is the weakest link in that chain:
|
||||
@@ -18,7 +18,7 @@
|
||||
# This wrapper is therefore the whole security boundary. It is named in the
|
||||
# authorized_keys `command=` option, so sshd runs it *instead of* whatever the
|
||||
# client asked for, and the client's request text is handed to us in the
|
||||
# environment variable SSH_ORIGINAL_COMMAND. A compromise of roadie should
|
||||
# environment variable SSH_ORIGINAL_COMMAND. A compromise of the runner should
|
||||
# yield the ability to run the publishing steps, and nothing else.
|
||||
#
|
||||
# The rules below are load-bearing. If you are reading this because you want to
|
||||
@@ -86,19 +86,19 @@ VERSION = "1.0"
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# One script, two roles. The role is read from a file rather than compiled in,
|
||||
# so that procyon and paxsor run byte-identical copies of the wrapper and a
|
||||
# so that both servers run byte-identical copies of the wrapper and a
|
||||
# review of one is a review of both. The file contains exactly one word.
|
||||
#
|
||||
ROLE_FILE = "/etc/pga-publish.role"
|
||||
ROLE_STAGING = "staging" # procyon, developer.pgadmin.org
|
||||
ROLE_DOWNLOAD = "download" # paxsor, ftp.pgadmin.org
|
||||
ROLE_STAGING = "staging" # developer.pgadmin.org
|
||||
ROLE_DOWNLOAD = "download" # ftp.pgadmin.org
|
||||
VALID_ROLES = (ROLE_STAGING, ROLE_DOWNLOAD)
|
||||
|
||||
# Filesystem roots. Nothing this script touches lives outside these, and every
|
||||
# path handed to a child process is built from one of them plus components that
|
||||
# have been through assert_component().
|
||||
STAGING_ROOT = "/var/www/html/builds" # procyon
|
||||
FTP_ROOT = "/var/ftp/pgadmin4" # paxsor
|
||||
STAGING_ROOT = "/var/www/html/builds" # the staging server
|
||||
FTP_ROOT = "/var/ftp/pgadmin4" # the download server
|
||||
SNAPSHOT_ROOT = os.path.join(FTP_ROOT, "snapshots")
|
||||
SNAPSHOT_KEEP = 5 # as pgadmin4-all-snapshot kept
|
||||
TOOLS_DIR = "/var/www/pgaweb/tools" # the pgaweb website scripts
|
||||
@@ -114,7 +114,8 @@ BIN_SSH = "/usr/bin/ssh"
|
||||
BIN_PYTHON = "/usr/bin/python3"
|
||||
|
||||
# The signing identity. Hard-coded on purpose: the client never says which key
|
||||
# to sign with, so a compromise of roadie cannot ask for a signature from some
|
||||
# to sign with, so a compromise of the runner cannot ask for a signature from
|
||||
# some
|
||||
# other secret key that happens to be in the keyring.
|
||||
GPG_KEY = "packages@pgadmin.org"
|
||||
GNUPGHOME = os.path.expanduser("~/.gnupg")
|
||||
@@ -151,6 +152,12 @@ YUM_TARGETS = (
|
||||
# The package trees inside a staging directory, derived from the tables above
|
||||
# so that the two cannot disagree.
|
||||
APT_COMPONENT = "main"
|
||||
|
||||
# What packages-fetch must not bring across from staging, because the live
|
||||
# tree's own copies are still the ones its signed metadata describes. The
|
||||
# rebuild verbs regenerate both from whatever is on disk afterwards.
|
||||
APT_INDEX_FILES = ("Packages", "Packages.*")
|
||||
YUM_INDEX_FILES = ("repodata",)
|
||||
YUM_TREES = tuple("yum/%s/%s-%s-%s" % (family, name, version, arch)
|
||||
for family, name, version, arch in YUM_TARGETS)
|
||||
|
||||
@@ -163,13 +170,24 @@ YUM_TREES = tuple("yum/%s/%s-%s-%s" % (family, name, version, arch)
|
||||
CLOUDFRONT_CONF = "/etc/pga-publish.cloudfront"
|
||||
|
||||
# How the download server pulls from the staging server. The source host is a
|
||||
# constant here; the client never names a host. The key on paxsor is confined
|
||||
# on the procyon side by rrsync in read-only mode, so the worst this edge can do
|
||||
# constant here; the client never names a host. The key the download server
|
||||
# uses is confined on the staging side by rrsync in read-only mode, so the
|
||||
# worst this edge can do
|
||||
# is read a tree that developer.pgadmin.org already publishes over HTTP anyway.
|
||||
# SITE: use the internal name or address that paxsor actually resolves.
|
||||
PULL_HOST = "procyon"
|
||||
# Which host the download server pulls staged content from. Read from the
|
||||
# server rather than written here, for two reasons.
|
||||
#
|
||||
# It is site configuration, like the CloudFront distribution, and a public
|
||||
# repository is the wrong place to record how one machine reaches another.
|
||||
#
|
||||
# More importantly it is read here rather than accepted from the client. The
|
||||
# caller names a verb and a datestamp; it never names a host, a path, a binary
|
||||
# or a signing key, which is what stops a compromised runner asking for a
|
||||
# signature over something it fetched from somewhere of its own choosing. A
|
||||
# workflow variable would put this on the wrong side of that line.
|
||||
PULL_HOST_CONF = "/etc/pga-publish.pull-host"
|
||||
PULL_USER = "pgaupload"
|
||||
PULL_KEY = os.path.expanduser("~/.ssh/id_ed25519_paxsor_pull")
|
||||
PULL_KEY = os.path.expanduser("~/.ssh/pga-pull")
|
||||
|
||||
# One publishing operation at a time. Two concurrent workflow runs indexing the
|
||||
# same repository would produce a Release file describing packages that are half
|
||||
@@ -335,8 +353,16 @@ def under(root, *components):
|
||||
# They are deliberately strict about shape: a datestamp is a real calendar date
|
||||
# in a plausible range, not merely eight digits and some hyphens.
|
||||
|
||||
DATESTAMP_RE = re.compile(r"\A(\d{4})-(\d{2})-(\d{2})(?:-([1-9]\d?))?\Z")
|
||||
VERSION_RE = re.compile(r"\A(\d{1,2})\.(\d{1,2})(?:\.(\d{1,2}))?\Z")
|
||||
# re.ASCII on both, because \d otherwise matches every Unicode decimal digit
|
||||
# and int() parses those just as happily, so a directory whose name is written
|
||||
# in fullwidth or Devanagari digits reads back as a perfectly ordinary date.
|
||||
# The character gate would catch that in a request, but these patterns are
|
||||
# also applied to names taken from the filesystem, where the upload key chose
|
||||
# them, and only ASCII digits are ever meant.
|
||||
DATESTAMP_RE = re.compile(r"\A(\d{4})-(\d{2})-(\d{2})(?:-([1-9]\d?))?\Z",
|
||||
re.ASCII)
|
||||
VERSION_RE = re.compile(r"\A(\d{1,2})\.(\d{1,2})(?:\.(\d{1,2}))?\Z",
|
||||
re.ASCII)
|
||||
|
||||
|
||||
def v_datestamp(value, raw):
|
||||
@@ -414,7 +440,7 @@ def child_env():
|
||||
DRY_RUN = False
|
||||
|
||||
|
||||
def run(argv, cwd=None, stdout_path=None):
|
||||
def run(argv, cwd=None, stdout_path=None, capture=False):
|
||||
"""Execute a fixed argv. Never a string, never a shell.
|
||||
|
||||
stdin is /dev/null: none of these steps has any business reading from the
|
||||
@@ -424,6 +450,12 @@ def run(argv, cwd=None, stdout_path=None):
|
||||
stdout_path exists because apt-ftparchive writes its index to standard
|
||||
output and the Jenkins job redirected it. We do the redirection here, in
|
||||
Python, rather than reaching for a shell to do it for us.
|
||||
|
||||
capture returns the child's standard output as text instead of letting it
|
||||
through to the client, for the one caller that has to read what rsync said
|
||||
rather than merely pass it on. The text is a child's output rather than a
|
||||
client's input, but it can still carry names an upload chose, so it goes
|
||||
through sanitise_for_log() before it is printed or logged.
|
||||
"""
|
||||
log(syslog.LOG_INFO, "EXEC %s%s%s"
|
||||
% (" ".join(argv),
|
||||
@@ -435,18 +467,22 @@ def run(argv, cwd=None, stdout_path=None):
|
||||
% (" ".join(argv),
|
||||
" [cwd %s]" % cwd if cwd else "",
|
||||
" [> %s]" % stdout_path if stdout_path else ""))
|
||||
return
|
||||
return ""
|
||||
|
||||
out = None
|
||||
try:
|
||||
if stdout_path:
|
||||
out = open(stdout_path, "wb")
|
||||
if capture:
|
||||
destination = subprocess.PIPE
|
||||
else:
|
||||
destination = out if out else None
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=child_env(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=out if out else None,
|
||||
stdout=destination,
|
||||
shell=False, # the default; stated to make the audit easy
|
||||
check=False,
|
||||
)
|
||||
@@ -461,6 +497,10 @@ def run(argv, cwd=None, stdout_path=None):
|
||||
% (argv[0], result.returncode))
|
||||
sys.exit(EX_FAIL)
|
||||
|
||||
if capture:
|
||||
return result.stdout.decode("utf-8", "replace")
|
||||
return ""
|
||||
|
||||
|
||||
def makedirs(path):
|
||||
"""Create a directory that we have already proved is inside its root."""
|
||||
@@ -499,6 +539,45 @@ def refuse_if_exists(path, reason):
|
||||
# Shared building blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Where rsync parks a file whilst --delay-updates holds it back. The name is
|
||||
# rsync's default and is not configured anywhere: --partial-dir would move it,
|
||||
# which is exactly what we do not want, for the reason clear_delayed_updates()
|
||||
# gives.
|
||||
PARTIAL_DIR = ".~tmp~"
|
||||
|
||||
|
||||
def clear_delayed_updates(tree):
|
||||
"""Remove any holding directory an interrupted pull left behind.
|
||||
|
||||
--delay-updates stages each incoming file under .~tmp~ in its own
|
||||
destination directory and renames the lot into place at the end, which is
|
||||
what makes a pull either wholly visible or not visible at all. A transfer
|
||||
that dies in the middle leaves those part-written files sitting in the
|
||||
published tree, and both apt-ftparchive and createrepo_c descend into
|
||||
whatever they find, so the next rebuild would index a truncated package,
|
||||
sign the index that describes it, and sync-s3 would then put both in an
|
||||
archive that has no --delete.
|
||||
|
||||
The holding directory stays where rsync puts it rather than being moved out
|
||||
of the tree with --partial-dir, because the final rename is only atomic
|
||||
whilst source and destination are on one filesystem, and the account's home
|
||||
directory is not guaranteed to be on the same one as /var/ftp; a
|
||||
--partial-dir elsewhere would quietly turn the rename into a copy and give
|
||||
back the half-visible publication that --delay-updates exists to prevent.
|
||||
Sweeping instead, immediately before indexing, costs a walk of one tree and
|
||||
is the last moment at which the leftovers could do any harm.
|
||||
"""
|
||||
if DRY_RUN or not os.path.isdir(tree):
|
||||
return
|
||||
for directory, subdirs, _names in os.walk(tree):
|
||||
if PARTIAL_DIR not in subdirs:
|
||||
continue
|
||||
subdirs.remove(PARTIAL_DIR) # nothing below it is worth walking
|
||||
stale = os.path.join(directory, PARTIAL_DIR)
|
||||
log(syslog.LOG_WARNING, "STALE %s" % stale)
|
||||
shutil.rmtree(stale)
|
||||
|
||||
|
||||
def index_apt(root, codename):
|
||||
"""Index and sign one apt tree, by running the script that does that.
|
||||
|
||||
@@ -508,13 +587,21 @@ def index_apt(root, codename):
|
||||
staging trees are indexed with, and what production is indexed with. The
|
||||
wrapper deliberately does not carry a second copy, because a second copy is
|
||||
how the staging and production indexes came to differ in the first place.
|
||||
|
||||
The sweep is here rather than in that script because the leftovers are this
|
||||
wrapper's own doing, from its pulls, and because the script is run by hand
|
||||
against whatever -r it is given: a recursive delete is a thing to keep on
|
||||
the side of the boundary where deletion is already confined and logged.
|
||||
"""
|
||||
clear_delayed_updates(under(root, "apt", codename))
|
||||
run([os.path.join(PUBLISH_DIR, "rebuild-apt-repo.sh"),
|
||||
"-r", root, codename])
|
||||
|
||||
|
||||
def index_yum(root, family, name, version, arch):
|
||||
"""Index and sign one yum tree, including the EL compatibility links."""
|
||||
clear_delayed_updates(under(root, "yum", family,
|
||||
"%s-%s-%s" % (name, version, arch)))
|
||||
run([os.path.join(PUBLISH_DIR, "rebuild-yum-repo.sh"),
|
||||
"-r", root, "-a", arch, family, name, version])
|
||||
|
||||
@@ -590,31 +677,116 @@ def sign_packages(root):
|
||||
"--armour", "--detach-sign", "--output", signature, target])
|
||||
|
||||
|
||||
def pull_from_staging(datestamp, src_subpath, dest):
|
||||
"""Copy one subdirectory of a staging build from procyon to paxsor.
|
||||
def pull_from_staging(datestamp, src_subpath, dest, excludes=()):
|
||||
"""Copy one subdirectory of a staging build to the download server.
|
||||
|
||||
The remote path is relative because the key used here is confined on the
|
||||
procyon side by `rrsync -ro /var/www/html/builds`, so rrsync resolves it
|
||||
staging side by `rrsync -ro /var/www/html/builds`, so rrsync resolves it
|
||||
against that root and refuses anything that climbs out. That is the real
|
||||
control; the validation on this side is the belt to its braces.
|
||||
|
||||
-e is given as a fixed list of words. rsync splits it on whitespace and
|
||||
execs it directly, without a shell, so the same rule applies here as
|
||||
everywhere else: no client input goes anywhere near it.
|
||||
|
||||
--ignore-existing is what keeps a promotion from rewriting history. The
|
||||
live tree's copy of a package is the one its signed metadata describes and
|
||||
the one the mirrors and the archive bucket already hold, so a staging build
|
||||
that happens to carry the same filename must not become those bytes: the
|
||||
rebuild that follows would sign the replacement, and every client that had
|
||||
already fetched the original would be holding something else entirely.
|
||||
Returns whatever was skipped, because a collision during a genuine
|
||||
promotion means two builds have produced one filename and somebody needs to
|
||||
know which one is live.
|
||||
"""
|
||||
remote = "%s@%s:%s/%s/" % (PULL_USER, PULL_HOST, datestamp, src_subpath)
|
||||
remote = "%s@%s:%s/%s/" % (PULL_USER, pull_host(), datestamp, src_subpath)
|
||||
ssh_cmd = ("%s -i %s -o BatchMode=yes -o StrictHostKeyChecking=yes"
|
||||
% (BIN_SSH, PULL_KEY))
|
||||
makedirs(dest)
|
||||
argv = [BIN_RSYNC,
|
||||
"-rlt", # no -p, no -o, no -g: local ownership wins
|
||||
"--safe-links", # drop any symlink pointing outside the tree
|
||||
"--no-specials", "--no-devices",
|
||||
"--delay-updates",
|
||||
"--ignore-existing",
|
||||
"--info=skip"] # name what was skipped, do not pass it over
|
||||
# Module constants, never anything from the client.
|
||||
for pattern in excludes:
|
||||
argv.append("--exclude=%s" % pattern)
|
||||
argv += ["-e", ssh_cmd, remote, dest + "/"]
|
||||
skipped = [line.strip() for line in run(argv, capture=True).splitlines()
|
||||
if line.strip()]
|
||||
for line in skipped:
|
||||
log(syslog.LOG_WARNING, "SKIPPED %s: %s"
|
||||
% (dest, sanitise_for_log(line)))
|
||||
return skipped
|
||||
|
||||
|
||||
def pull_host():
|
||||
"""The staging server's name, from the file the site owns.
|
||||
|
||||
Validated as a hostname rather than trusted, because it becomes part of an
|
||||
rsync remote specification: a value carrying a colon or a space would
|
||||
change what that argument means.
|
||||
"""
|
||||
try:
|
||||
with open(PULL_HOST_CONF) as handle:
|
||||
host = handle.read().strip()
|
||||
except OSError:
|
||||
if DRY_RUN:
|
||||
return "staging.example.com"
|
||||
sys.stderr.write("pga-publish: cannot read %s\n" % PULL_HOST_CONF)
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
|
||||
if not re.match(r"\A[A-Za-z0-9][A-Za-z0-9.-]{0,252}[A-Za-z0-9]\Z", host):
|
||||
sys.stderr.write("pga-publish: invalid host in %s\n" % PULL_HOST_CONF)
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
return host
|
||||
|
||||
|
||||
def pull_staging_files(datestamp, dest):
|
||||
"""Copy the loose downloads of a staging build, and nothing else.
|
||||
|
||||
A staging build is flat: apt/ and yum/ beside the files a user downloads
|
||||
directly. Those two are excluded here because packages-fetch handles them,
|
||||
and pulling several gigabytes of repository twice would be a poor way to
|
||||
publish a release.
|
||||
"""
|
||||
remote = "%s@%s:%s/" % (PULL_USER, pull_host(), datestamp)
|
||||
ssh_cmd = ("%s -i %s -o BatchMode=yes -o StrictHostKeyChecking=yes"
|
||||
% (BIN_SSH, PULL_KEY))
|
||||
makedirs(dest)
|
||||
# -r with the two trees excluded, rather than no -r at all: without it
|
||||
# rsync reports "skipping directory ." and transfers nothing, which is
|
||||
# exactly what the first rehearsal did.
|
||||
run([BIN_RSYNC,
|
||||
"-rlt", # no -p, no -o, no -g: local ownership wins
|
||||
"--safe-links", # drop any symlink pointing outside the tree
|
||||
"-rlt",
|
||||
"--safe-links",
|
||||
"--no-specials", "--no-devices",
|
||||
"--delay-updates",
|
||||
"--exclude", "/apt/***", "--exclude", "/yum/***",
|
||||
"-e", ssh_cmd,
|
||||
remote, dest + "/"])
|
||||
|
||||
|
||||
# Where each kind of download belongs once it reaches a release directory.
|
||||
# Order matters: the documentation tarball is also a .tar.gz, so it has to be
|
||||
# recognised before the source tarball is.
|
||||
RELEASE_FILE_MAP = (
|
||||
("-docs.tar.gz", "docs"),
|
||||
(".pdf", "docs"),
|
||||
(".epub", "docs"),
|
||||
(".tar.gz", "source"),
|
||||
(".whl", "pip"),
|
||||
(".dmg", "macos"),
|
||||
(".exe", "windows"),
|
||||
)
|
||||
|
||||
# The Electron auto-updater reads these, and only ever wants the current
|
||||
# release, so unlike everything else they replace rather than accumulate.
|
||||
AUTOUPDATE_DIR = os.path.join(FTP_ROOT, "autoupdate", "macos")
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
"""One mutating operation at a time, per host."""
|
||||
if DRY_RUN:
|
||||
@@ -631,13 +803,14 @@ def acquire_lock():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verb handlers: staging role (procyon)
|
||||
# Verb handlers: staging role (the staging server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def index_tree(root, url, kind):
|
||||
"""Index, sign and document one kind of repository inside a build tree.
|
||||
|
||||
Used for both a staging build on procyon and a snapshot on the download
|
||||
Used for both a staging build on the staging server and a snapshot on the
|
||||
download
|
||||
server, which differ in where they live and in nothing else.
|
||||
"""
|
||||
if kind == "apt":
|
||||
@@ -758,7 +931,7 @@ def cmd_stage_sign(args, raw):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verb handlers: download role (paxsor)
|
||||
# Verb handlers: download role (the download server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_release_exists(args, raw):
|
||||
@@ -789,26 +962,195 @@ def cmd_release_create(args, raw):
|
||||
refuse_if_exists(root, "publication location already exists")
|
||||
makedirs(root)
|
||||
for name in RELEASE_CONTENT_DIRS:
|
||||
makedirs(under(root, name))
|
||||
target = under(root, name)
|
||||
makedirs(target)
|
||||
write_maintainer(target)
|
||||
print("created %s" % root)
|
||||
|
||||
|
||||
def write_maintainer(directory):
|
||||
"""Drop the maintainer marker the PostgreSQL mirror network expects.
|
||||
|
||||
Fixed content, held as a file beside the scripts rather than echoed inline
|
||||
as the buildfarm did, so that changing the support address is a reviewed
|
||||
commit rather than an edit to nine job definitions.
|
||||
"""
|
||||
src = os.path.join(PUBLISH_DIR, "CURRENT_MAINTAINER")
|
||||
dst = os.path.join(directory, "CURRENT_MAINTAINER")
|
||||
if DRY_RUN:
|
||||
print("would copy: %s -> %s" % (src, dst))
|
||||
return
|
||||
if not os.path.isfile(src):
|
||||
sys.stderr.write("pga-publish: missing %s\n" % src)
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
shutil.copyfile(src, dst)
|
||||
|
||||
|
||||
def refuse_if_release_touched(root, incoming, version):
|
||||
"""Refuse unless v<VERSION> is still exactly what release-create left.
|
||||
|
||||
A freshly created release directory holds the content subdirectories and
|
||||
nothing in them but the maintainer marker, so anything else is either a
|
||||
promotion already run or an attempt to move new files over published ones.
|
||||
Both are refused, and the operator is told the answer is a new version
|
||||
rather than a repair, because by the time a release is on the mirrors and
|
||||
in the archive bucket there is no version of it left to correct.
|
||||
|
||||
The half-finished case is caught by the same rule. The pull lands in
|
||||
.incoming and nothing removes that directory until autoupdate-publish has
|
||||
taken the archives out of it, so finding one here means a promotion
|
||||
stopped partway and wants a human rather than a second attempt.
|
||||
"""
|
||||
if DRY_RUN:
|
||||
return
|
||||
|
||||
problems = []
|
||||
if os.path.exists(incoming):
|
||||
problems.append(".incoming/ (a promotion stopped partway)")
|
||||
for subdir in RELEASE_CONTENT_DIRS:
|
||||
directory = under(root, subdir)
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
published = [name for name in sorted(os.listdir(directory))
|
||||
if name != "CURRENT_MAINTAINER"]
|
||||
if published:
|
||||
problems.append("%s/ (%s)" % (subdir, ", ".join(published)))
|
||||
|
||||
if not problems:
|
||||
return
|
||||
|
||||
detail = sanitise_for_log("; ".join(problems))
|
||||
log(syslog.LOG_WARNING, "PRECONDITION v%s is not empty: %s"
|
||||
% (version, detail))
|
||||
sys.stderr.write("pga-publish: v%s already holds published files: %s\n"
|
||||
% (version, detail))
|
||||
sys.stderr.write("pga-publish: publication is single-shot, so nothing "
|
||||
"here will be overwritten. Publish a new version rather "
|
||||
"than repairing this one.\n")
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
|
||||
|
||||
def cmd_release_fetch(args, raw):
|
||||
"""release-fetch <VERSION> <DATESTAMP>
|
||||
|
||||
Copy the content directories of a staging build into a release directory
|
||||
that release-create has already made. Both arguments are validated; the
|
||||
source host, the transport and the subdirectory list are all constants.
|
||||
Copy the downloads of a staging build into a release directory that
|
||||
release-create has already made, sorting them as they arrive.
|
||||
|
||||
The sorting is the part worth understanding. A staging build keeps its
|
||||
downloads loose at the top level, whilst a published release divides them
|
||||
into docs, macos, pip, source and windows; the buildfarm did this with a
|
||||
series of scp commands carrying shell globs. Here the map is a table, the
|
||||
destination is derived from the file's own name, and anything unrecognised
|
||||
stops the publication rather than being dropped silently, because a new
|
||||
kind of artefact appearing is exactly the moment somebody should look.
|
||||
|
||||
Single-shot, and enforced here rather than left to release-create. That
|
||||
verb refuses to make a directory that already exists, which made the first
|
||||
step of a promotion single-shot and left the rest of it quietly repeatable:
|
||||
run against a version published months ago, this one would move staging's
|
||||
files over the published ones, and autoupdate-publish and sync-s3 would
|
||||
then carry the replacements to the auto-updater and to an archive that has
|
||||
no --delete and therefore no undo. The guarantee the README and the
|
||||
authorized_keys annotations make is about publication, not about one verb
|
||||
of it, so it is checked over the whole sequence: before anything moves, and
|
||||
again for each file as it moves.
|
||||
"""
|
||||
version = v_version(args[0], raw)
|
||||
datestamp = v_datestamp(args[1], raw)
|
||||
root = under(FTP_ROOT, "v%s" % version)
|
||||
require_dir(root, "run release-create first")
|
||||
for name in RELEASE_CONTENT_DIRS:
|
||||
pull_from_staging(datestamp, name, under(root, name))
|
||||
|
||||
incoming = under(root, ".incoming")
|
||||
refuse_if_release_touched(root, incoming, version)
|
||||
pull_staging_files(datestamp, incoming)
|
||||
|
||||
if DRY_RUN:
|
||||
print("would sort the staging downloads into %s" % root)
|
||||
return
|
||||
|
||||
unknown = []
|
||||
for name in sorted(os.listdir(incoming)):
|
||||
source = os.path.join(incoming, name)
|
||||
if not os.path.isfile(source):
|
||||
continue
|
||||
|
||||
# The ZIPs belong to the auto-updater rather than to this release
|
||||
# directory, and autoupdate-publish deals with them: leaving them here
|
||||
# keeps this verb confined to v<VERSION>, which is what makes it the
|
||||
# one step of a promotion that deleting a directory undoes.
|
||||
if name.endswith(".zip"):
|
||||
continue
|
||||
|
||||
for suffix, subdir in RELEASE_FILE_MAP:
|
||||
if name.endswith(suffix) or name.endswith(suffix + ".asc"):
|
||||
# Checked per file as well as once up front, because the guard
|
||||
# above ran before the pull and shutil.move onto an existing
|
||||
# regular file replaces it without a word. This is the moment a
|
||||
# published file would actually be overwritten, so this is
|
||||
# where the refusal belongs.
|
||||
target = os.path.join(under(root, subdir), name)
|
||||
refuse_if_exists(
|
||||
target,
|
||||
"v%s already publishes %s, and a published file is never "
|
||||
"replaced: publish a new version rather than repairing "
|
||||
"this one" % (version, sanitise_for_log(name)))
|
||||
shutil.move(source, target)
|
||||
break
|
||||
else:
|
||||
unknown.append(name)
|
||||
|
||||
if unknown:
|
||||
for name in unknown:
|
||||
log(syslog.LOG_WARNING, "UNSORTED %s" % name)
|
||||
sys.stderr.write("pga-publish: no destination for: %s\n"
|
||||
% ", ".join(unknown))
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
|
||||
# Whatever is left is the auto-updater's, and autoupdate-publish moves it.
|
||||
print("fetched %s into %s" % (datestamp, root))
|
||||
|
||||
|
||||
def cmd_autoupdate_publish(args, raw):
|
||||
"""autoupdate-publish <VERSION>
|
||||
|
||||
Move the ZIPs left behind by release-fetch into the auto-updater's
|
||||
directory, replacing the previous release's rather than accumulating
|
||||
beside them, because Electron looks for exactly one.
|
||||
|
||||
Separate from release-fetch because it writes outside v<VERSION>, and
|
||||
release-fetch being confined to that one directory is what makes it the
|
||||
part of a promotion that can be rehearsed and then simply deleted.
|
||||
"""
|
||||
version = v_version(args[0], raw)
|
||||
incoming = under(FTP_ROOT, "v%s" % version, ".incoming")
|
||||
require_dir(incoming, "run release-fetch first")
|
||||
|
||||
zips = sorted(n for n in os.listdir(incoming) if n.endswith(".zip"))
|
||||
if not zips:
|
||||
sys.stderr.write("pga-publish: no auto-updater archives in %s\n"
|
||||
% incoming)
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
|
||||
makedirs(AUTOUPDATE_DIR)
|
||||
write_maintainer(AUTOUPDATE_DIR)
|
||||
if not DRY_RUN:
|
||||
# The new archives in first, the old ones out afterwards. Removing
|
||||
# them first left a window, however brief, in which the directory held
|
||||
# no archive at all, and a move that failed in the middle of it, or a
|
||||
# connection dropped between the two loops, made that window permanent:
|
||||
# every pgAdmin asking for an update would be told there was nothing
|
||||
# there. This way the worst an interrupted run leaves behind is both
|
||||
# releases present at once, which is a tidy-up rather than an outage.
|
||||
for name in zips:
|
||||
shutil.move(os.path.join(incoming, name),
|
||||
os.path.join(AUTOUPDATE_DIR, name))
|
||||
for stale in sorted(os.listdir(AUTOUPDATE_DIR)):
|
||||
if stale.endswith(".zip") and stale not in zips:
|
||||
os.unlink(os.path.join(AUTOUPDATE_DIR, stale))
|
||||
os.rmdir(incoming)
|
||||
print("published %d auto-updater archive(s)" % len(zips))
|
||||
|
||||
|
||||
def cmd_packages_fetch(args, raw):
|
||||
"""packages-fetch <DATESTAMP>
|
||||
|
||||
@@ -818,18 +1160,45 @@ def cmd_packages_fetch(args, raw):
|
||||
There is no shared apt pool: each distribution release has its own tree,
|
||||
with the packages sitting directly in binary-<arch>, which is the layout
|
||||
apt-ftparchive is pointed at and the one the download site has always had.
|
||||
Only the indices are left behind, because the rebuild verbs regenerate
|
||||
those from whatever is on disk afterwards.
|
||||
|
||||
The indices are deliberately left behind, and the exclusions below are
|
||||
what implements that. Staging's Packages files describe staging's tree,
|
||||
whilst the signed Release, Release.gpg and InRelease that clients check
|
||||
them against live one directory further up and are not part of this copy;
|
||||
importing the former without the latter leaves every live apt repository
|
||||
failing on a hash sum mismatch until the rebuild catches up, which is
|
||||
minutes later given the rebuilds are sequential and each takes the lock.
|
||||
The same applies to yum's repodata, more gently: a signed repomd.xml
|
||||
describing the wrong package set rather than an outright failure. Keeping
|
||||
the old indices means the tree stays self-consistent throughout, with the
|
||||
new packages simply invisible until the rebuild verb indexes them.
|
||||
"""
|
||||
datestamp = v_datestamp(args[0], raw)
|
||||
skipped = []
|
||||
for codename in APT_CODENAMES:
|
||||
relative = "apt/%s/dists/pgadmin4/%s" % (codename, APT_COMPONENT)
|
||||
pull_from_staging(datestamp, relative,
|
||||
under(FTP_ROOT, "apt", codename, "dists",
|
||||
"pgadmin4", APT_COMPONENT))
|
||||
skipped += pull_from_staging(
|
||||
datestamp, relative,
|
||||
under(FTP_ROOT, "apt", codename, "dists",
|
||||
"pgadmin4", APT_COMPONENT),
|
||||
excludes=APT_INDEX_FILES)
|
||||
for relative in YUM_TREES:
|
||||
pull_from_staging(datestamp, relative,
|
||||
under(FTP_ROOT, *relative.split("/")))
|
||||
skipped += pull_from_staging(datestamp, relative,
|
||||
under(FTP_ROOT, *relative.split("/")),
|
||||
excludes=YUM_INDEX_FILES)
|
||||
|
||||
# Not fatal, because the rest of the promotion is still worth finishing and
|
||||
# nothing has been damaged, but loud, because this is how a maintainer
|
||||
# finds out that the packages now live are not the ones this build
|
||||
# produced.
|
||||
if skipped:
|
||||
sys.stderr.write("pga-publish: %d file(s) were already published and "
|
||||
"have been left as they are:\n" % len(skipped))
|
||||
for line in skipped:
|
||||
sys.stderr.write(" %s\n" % sanitise_for_log(line))
|
||||
sys.stderr.write("pga-publish: a live package is never replaced. If "
|
||||
"this is a real promotion, two builds have produced "
|
||||
"the same filename and the live one wins.\n")
|
||||
print("fetched packages from %s" % datestamp)
|
||||
|
||||
|
||||
@@ -882,27 +1251,82 @@ def cmd_snapshot_index_yum(args, raw):
|
||||
print("indexed %s/yum" % root)
|
||||
|
||||
|
||||
def snapshot_date(name):
|
||||
"""The date a snapshot directory is named for, or None if it is not one.
|
||||
|
||||
Retention turns on the date alone, so the -N suffix that distinguishes the
|
||||
second and later builds of a day is parsed only to confirm the name is a
|
||||
snapshot name and is then discarded: two builds on the same day are kept
|
||||
or removed together.
|
||||
|
||||
A name the regex accepts can still carry an impossible date, 2026-02-31
|
||||
say, in which case it is not a snapshot either and is left alone.
|
||||
"""
|
||||
match = DATESTAMP_RE.match(name)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return date(*(int(match.group(i)) for i in (1, 2, 3)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def cmd_snapshot_purge(args, raw):
|
||||
"""snapshot-purge
|
||||
|
||||
Keep the newest SNAPSHOT_KEEP snapshots and remove the rest, as
|
||||
pgadmin4-all-snapshot did with `ls -dt | tail -n +6 | xargs rm -rf`.
|
||||
Keep every snapshot built on one of the newest SNAPSHOT_KEEP dates and
|
||||
remove the rest, in place of the buildfarm's
|
||||
`ls -dt | tail -n +6 | xargs rm -rf`.
|
||||
|
||||
Retention is therefore counted in days rather than in directories, and
|
||||
that distinction is the whole point of the rule. Keeping the newest five
|
||||
directories sounds equivalent but hands the decision to whoever can create
|
||||
a directory, which both keys can: snapshot-create makes one with the
|
||||
publishing key, and an upload under a name of the client's choosing makes
|
||||
one with the upload key, since rrsync confines where a path may land but
|
||||
has nothing to say about what it is called. Five directories dated today,
|
||||
with high suffixes, filled the keep set and every genuine snapshot fell
|
||||
off the end of it. Counting days makes that impossible, because the newest
|
||||
date anybody can produce is today, and today is kept however many
|
||||
directories carry it.
|
||||
|
||||
The consequence is that a day with several builds keeps all of that day's
|
||||
builds, which is intended rather than accidental: the tree holds the last
|
||||
five days of snapshots rather than the last five snapshots, and a day that
|
||||
rebuilt four times keeps four.
|
||||
|
||||
This is the one verb that deletes anything, so it takes no argument: it
|
||||
cannot be asked to remove a particular snapshot, only to enforce the
|
||||
retention the server already has. Directories that do not look like a
|
||||
snapshot are left alone rather than swept up, because the day somebody
|
||||
parks something in that tree by hand is the day a tidy-up should not eat
|
||||
it.
|
||||
it, and so are any dated after today, since those cannot be snapshots this
|
||||
server has built.
|
||||
"""
|
||||
if not os.path.isdir(SNAPSHOT_ROOT):
|
||||
print("no snapshots")
|
||||
return
|
||||
|
||||
names = sorted(name for name in os.listdir(SNAPSHOT_ROOT)
|
||||
if DATESTAMP_RE.match(name)
|
||||
and os.path.isdir(os.path.join(SNAPSHOT_ROOT, name)))
|
||||
doomed = names[:-SNAPSHOT_KEEP] if len(names) > SNAPSHOT_KEEP else []
|
||||
today = date.today()
|
||||
dated = []
|
||||
for name in os.listdir(SNAPSHOT_ROOT):
|
||||
when = snapshot_date(name)
|
||||
if when is None:
|
||||
continue
|
||||
if not os.path.isdir(os.path.join(SNAPSHOT_ROOT, name)):
|
||||
continue
|
||||
# A directory dated in the future is not a snapshot this server has
|
||||
# built, so it neither counts towards the retention nor gets removed
|
||||
# by it. Keeping whole days already stops a flood of today-dated
|
||||
# directories evicting anything, but a date years hence would still
|
||||
# occupy a day of the keep set for as long as it sat there.
|
||||
if when > today:
|
||||
continue
|
||||
dated.append((when, name))
|
||||
|
||||
keep = sorted({when for when, _ in dated}, reverse=True)[:SNAPSHOT_KEEP]
|
||||
doomed = sorted(name for when, name in dated if when not in keep)
|
||||
|
||||
for name in doomed:
|
||||
path = under(SNAPSHOT_ROOT, name)
|
||||
log(syslog.LOG_NOTICE, "PURGE %s" % path)
|
||||
@@ -910,8 +1334,8 @@ def cmd_snapshot_purge(args, raw):
|
||||
print("would remove: %s" % path)
|
||||
else:
|
||||
shutil.rmtree(path)
|
||||
print("kept %d snapshot(s), removed %d"
|
||||
% (min(len(names), SNAPSHOT_KEEP), len(doomed)))
|
||||
print("kept %d snapshot(s) across %d day(s), removed %d"
|
||||
% (len(dated) - len(doomed), len(keep), len(doomed)))
|
||||
|
||||
|
||||
def cmd_rebuild_apt(args, raw):
|
||||
@@ -943,6 +1367,26 @@ def cmd_rebuild_yum(args, raw):
|
||||
print("rebuilt yum %s %s %s %s" % (family, name, elversion, arch))
|
||||
|
||||
|
||||
def cmd_rebuild_readme(args, raw):
|
||||
"""rebuild-readme <apt|yum>
|
||||
|
||||
Regenerate the README at the top of a production repository tree.
|
||||
|
||||
Unlike the staging and snapshot ones, this carries the release history:
|
||||
which platform first got a package, and which release was the last for a
|
||||
platform no longer supported. That comes from the S3 archive rather than
|
||||
from this server, because old releases are purged from here as they age,
|
||||
so deriving it locally would rewrite history every time somebody tidied up.
|
||||
"""
|
||||
kind = v_name(args[0], raw)
|
||||
if kind not in ("apt", "yum"):
|
||||
reject(EX_NOPERM, "unknown-repository-kind", raw)
|
||||
write_readme(FTP_ROOT, kind,
|
||||
"https://ftp.postgresql.org/pub/pgadmin/pgadmin4",
|
||||
history=True)
|
||||
print("rebuilt the %s README" % kind)
|
||||
|
||||
|
||||
def cmd_create_release(args, raw):
|
||||
"""create-release <VERSION>
|
||||
|
||||
@@ -982,6 +1426,19 @@ def cmd_sync_s3(args, raw):
|
||||
snapshots, so there is nothing here for a caller to name. Its one argument
|
||||
is the CloudFront distribution, which is site configuration and is read
|
||||
from the server rather than accepted from the client.
|
||||
|
||||
sync-ftp-to-s3.py is run from PUBLISH_DIR rather than TOOLS_DIR because it
|
||||
now ships in pkg/publish alongside the rebuild scripts: it writes to the
|
||||
download tree and the archive bucket and has nothing to do with the
|
||||
website, so keeping it in the pgaweb checkout meant the publishing
|
||||
pipeline depended on a repository that has no other part in it.
|
||||
|
||||
That makes this a deployment-ordering change. This wrapper, and the copy
|
||||
of the script in PUBLISH_DIR that it now names, must both be installed on
|
||||
the download server before the pgaweb side deletes its own copy; do it the
|
||||
other way round and the wrapper still deployed there goes looking in
|
||||
TOOLS_DIR for a file that is no longer present, and sync-s3 fails for as
|
||||
long as the gap lasts.
|
||||
"""
|
||||
try:
|
||||
with open(CLOUDFRONT_CONF) as handle:
|
||||
@@ -997,7 +1454,7 @@ def cmd_sync_s3(args, raw):
|
||||
sys.stderr.write("pga-publish: invalid distribution id in %s\n"
|
||||
% CLOUDFRONT_CONF)
|
||||
sys.exit(EX_UNAVAILABLE)
|
||||
run([BIN_PYTHON, os.path.join(TOOLS_DIR, "sync-ftp-to-s3.py"),
|
||||
run([BIN_PYTHON, os.path.join(PUBLISH_DIR, "sync-ftp-to-s3.py"),
|
||||
distribution])
|
||||
print("synced the download archive")
|
||||
|
||||
@@ -1037,7 +1494,7 @@ def cmd_hello(args, raw):
|
||||
VERBS = {
|
||||
"hello": (None, 0, 0, cmd_hello),
|
||||
|
||||
# procyon
|
||||
# the staging server
|
||||
"stage-create": (ROLE_STAGING, 1, 1, cmd_stage_create),
|
||||
"stage-list": (ROLE_STAGING, 0, 0, cmd_stage_list),
|
||||
"stage-exists": (ROLE_STAGING, 1, 1, cmd_stage_exists),
|
||||
@@ -1045,10 +1502,11 @@ VERBS = {
|
||||
"stage-index-apt": (ROLE_STAGING, 1, 1, cmd_stage_index_apt),
|
||||
"stage-index-yum": (ROLE_STAGING, 1, 1, cmd_stage_index_yum),
|
||||
|
||||
# paxsor
|
||||
# the download server
|
||||
"release-exists": (ROLE_DOWNLOAD, 1, 1, cmd_release_exists),
|
||||
"release-create": (ROLE_DOWNLOAD, 1, 1, cmd_release_create),
|
||||
"release-fetch": (ROLE_DOWNLOAD, 2, 2, cmd_release_fetch),
|
||||
"autoupdate-publish": (ROLE_DOWNLOAD, 1, 1, cmd_autoupdate_publish),
|
||||
"packages-fetch": (ROLE_DOWNLOAD, 1, 1, cmd_packages_fetch),
|
||||
"snapshot-create": (ROLE_DOWNLOAD, 1, 1, cmd_snapshot_create),
|
||||
"snapshot-sign": (ROLE_DOWNLOAD, 1, 1, cmd_snapshot_sign),
|
||||
@@ -1057,6 +1515,7 @@ VERBS = {
|
||||
"snapshot-purge": (ROLE_DOWNLOAD, 0, 0, cmd_snapshot_purge),
|
||||
"rebuild-apt": (ROLE_DOWNLOAD, 1, 1, cmd_rebuild_apt),
|
||||
"rebuild-yum": (ROLE_DOWNLOAD, 4, 4, cmd_rebuild_yum),
|
||||
"rebuild-readme": (ROLE_DOWNLOAD, 1, 1, cmd_rebuild_readme),
|
||||
"create-release": (ROLE_DOWNLOAD, 1, 1, cmd_create_release),
|
||||
"load-docs": (ROLE_DOWNLOAD, 1, 1, cmd_load_docs),
|
||||
"purge-cache": (ROLE_DOWNLOAD, 0, 0, cmd_purge_cache),
|
||||
|
||||