Compare commits

..
Author SHA1 Message Date
dependabot[bot] 2cef2cca5d Python dependency: Bump azure-identity
Bumps the python-minor-and-patch group with 1 update: [azure-identity](https://github.com/Azure/azure-sdk-for-python).


Updates `azure-identity` from 1.26.0 to 1.27.0
- [Release notes](https://github.com/Azure/azure-sdk-for-python/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-identity_1.26.0...azure-identity_1.27.0)

---
updated-dependencies:
- dependency-name: azure-identity
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-30 10:04:44 +00:00
Dave Page 48c8b7e609 Collect the PyPI wheel from the candidate, not the mirrors, and add a pypi-only promotion scope.
The mirrors sync from the download server hourly, so the release is not
on ftp.postgresql.org when the upload runs, and 9.19's promotion failed
with a 404 at that step. The candidate tree on developer.pgadmin.org holds
the same signed wheel the acceptance test installed, and the signature is
still verified before upload.

The pypi scope runs only the wheel collection, verification and upload,
so a promotion whose upload failed can be finished without repeating the
rest.
2026-10-29 15:34:40 +00:00
Dave Page 062099ae5b Retry stapler validate in the macOS build and acceptance checks. (#10686)
It looks the notarisation ticket up in Apple's CloudKit, and a dropped
connection there has failed both the arm64 build's verification step,
skipping the rest of a release candidate, and the acceptance test, on
bundles that were notarised and accepted by Gatekeeper.
2026-10-27 00:01:39 +00:00
Dave Page 0e7c33bee2 chore: Sign the versioned OpenSSL, gettext and iconv libraries in the Windows build. (#10684)
FOR treats an item containing a wildcard as a glob against the current
directory and drops it when nothing matches, so libcrypto-*-x64.dll,
libssl-*-x64.dll, libintl-*.dll and libiconv-*.dll never reached the
inner DIR and were shipped unsigned since #10355. Run the loop from the
runtime directory instead, checking each name exists.
2026-10-27 01:46:48 +05:30
Ashesh Vashi 5a082916f8 Collect only the release-candidate build outputs, not the build record.
The assemble job downloaded every artefact of the run, which includes the
'pgadmin-org~pgadmin4~*.dockerbuild' build record that
docker/build-push-action uploads for the container. That artefact is not
stored as a plain zip, so actions/download-artifact fails to extract it,
and because it was downloading everything, the whole step failed after
its retries with 'Artifact download failed after 5 retries' -- while every
real artefact downloaded fine. None of the build record is used here; the
staging tree is assembled only from the pgadmin4-* artefacts. Restrict the
download to pgadmin4-*, which leaves the build record behind.
2026-10-27 00:05:21 +05:30
Ashesh Vashi 0eac05b450 Grant packages: write to the release-candidate container job.
A called workflow's token is capped by the caller's permissions. The
release-candidate workflow sets 'contents: read' at the top level, which
leaves 'packages: none', so when it publishes the container with
publish: true the check-container-build publish-container job's
'packages: write' request exceeded the cap and the workflow was rejected
before starting (startup_failure). Grant packages: write on the container
caller job. The nightly snapshot is unaffected: it builds the container
as a check, with publish-container skipped, so it never requests the
scope.
2026-10-26 22:35:55 +05:30
Ashesh Vashi 7bbbb53e69 Skip the missing-client-file passthrough test on Windows.
Like the equivalent utility_conninfo case, a configured client file that
cannot be found is disabled (NO_CLIENT_FILE) on Windows rather than passed
on as its path; both are unopenable, so the connection still cannot use
another file. fs_short_path() also returns empty for a path that does not
exist on Windows, so the expectation could not be normalised. Run the
passthrough scenario on POSIX only.
2026-10-26 19:33:39 +05:30
Ashesh Vashi eb66e6a347 Make the 9.19 security tests pass on Windows.
The tests added for the 9.19 storage and connection hardening were
written on Linux and failed on Windows, where the behaviour they check
is correct but expressed differently. The production code is unchanged.

- Skip the Backup, Restore and Import/Export descriptor-based
  symlink-race tests on Windows: opening a file with O_NOFOLLOW and
  handing it to the utility as /dev/fd/N is POSIX-only, and the code
  already passes the real path there.
- Compare credential-file paths after fs_short_path()+forward slashes,
  the short (8.3) form libpq is given on Windows; this is the identity
  on POSIX. Covers the --dbname conninfo call sites, get_complete_file_path,
  client-file isolation and the SSH tunnel identity file.
- Normalise the File Manager closest-parent separator to '/'.
- Drop the other inherited credential variable before checking which
  remove_inherited_libpq_credentials() removes, as the runner's own
  environment may hold PGPASSWORD.
- Use forward slashes in the raw hostile --database value, whose
  backslashes libpq would otherwise treat as escapes, and skip the
  'missing configured file passed through' case on Windows, where such
  a file is disabled (NO_CLIENT_FILE) instead.
2026-10-26 19:02:39 +05:30
Ashesh Vashi 36122bc29b Skip the Kerberos require_auth tests where the installed libpq is older than 16.
require_auth needs libpq 16, so pgAdmin only adds it when the installed
libpq is new enough.  These tests mock the connection-parameter libpq
version to exercise that path, but create_connection_string() validates
the connection string, and the injection tests build and parse it, with
the real installed libpq.  On a runner whose libpq is older than 16 that
libpq rejects require_auth, so the scenarios that produce it errored on
macOS and Windows.  Skip a require_auth-producing scenario when the
installed libpq cannot parse it, as the one real-parser scenario in
test_kerberos_conninfo already did.
2026-10-26 17:34:28 +05:30
Kundan 6d035dc4fa Skip the view msql tests that expect DROP VIEW on EPAS. (#10660)
EPAS can change a view's columns with CREATE OR REPLACE VIEW, so pgAdmin
only drops and re-creates the view on PostgreSQL (#5053). The tests
added for #9838 expected DROP VIEW on every server and failed on EPAS.
2026-10-26 17:30:22 +05:30
Ashesh Vashi ea17a4e0ff Install libpq in the Docker env-builder so psycopg's C extension loads.
python:3-alpine now resolves to Python 3.15, whose psycopg[c] wheel links
against the system libpq.  postgresql-dev is removed with build-deps, so
libpq.so.5 was no longer present at runtime and importing psycopg (as the
Sphinx docs build does via config.py) failed with 'no pq wrapper
available', breaking the container build.  Install the runtime libpq
package separately so it survives the build-deps cleanup.
2026-10-26 15:43:59 +05:30
Ashesh Vashi a972ea204e Fix the 9.19 release notes using a math-italic pi where 'py' was meant, which broke the PDF docs build. 2026-10-26 15:43:59 +05:30
Dave Page 2551643aa1 chore: Add pgEdge Starfleet cloud deployment to the 9.19 release notes. 2026-10-26 08:56:33 +00:00
Ashesh Vashi b939b85ecb chore: Bump the version to 9.19.
Update APP_REVISION and APP_VERSION_INT in web/version.py, and the
Helm chart's version and appVersion.
2026-10-26 12:30:38 +05:30
Ashesh Vashi 4581fddd8d chore: Add the 9.19 security release notes and the release date.
One entry per tracking issue (#10645-#10676), with the CVE identifiers
for the 15 vulnerabilities.

Also set the release date to 2026-10-29, and add the upgrade guidance
for the SSH agent, Flask-Security-Too, non-administrator shares and
SERVER_MODE_ALLOWED_SERVICES.
2026-10-26 12:30:38 +05:30
Ashesh Vashi e13b12c492 chore: Add the release notes for the changes since 26a7d92b0 to 9.19. 2026-10-26 12:30:38 +05:30
Dave Page f4b5309267 chore: Add the release notes for 9.19. 2026-10-26 12:30:38 +05:30
Ashesh Vashi 9e985fef5b Updated message catalogs for v9.19 2026-10-25 23:26:24 +05:30
Ashesh Vashi 5ecdcd7201 Document the authentication behaviour changes for 9.19: separate accounts per source, lockout, session invalidation, and the user-management command examples.
Add a shared "Accounts from different authentication sources" section to the
user management docs and link to it from the LDAP, Kerberos, OAuth2 and
Webserver pages. It explains that an account is identified by its username
and its authentication source together, that an external login is never
logged in to an account from another source, and that a password reset acts
only on the internal account. It also describes the account-lockout
behaviour: MAX_LOGIN_ATTEMPTS applies only to internal accounts, pgAdmin
never locks an account from another source, a failed sign-in for a shared
username counts towards the internal account's lockout, and a locked internal
account stops only internal sign-in without disclosing the lock while another
source is configured.

The LDAP page also notes that, with internal and LDAP both enabled, a shared
username is tried against both sources in the configured order, and that
accounts whose usernames differ only in case should not be created when
LDAP_DN_CASE_SENSITIVE is off.

Note on the Change pgAdmin Password page that changing the password, or an
administrator resetting it, signs out the user's other sessions.

Fix the setup.py examples: add-external-user and update-external-user take
the authentication source through --auth-source, not as a positional
argument; update-user and update-external-user do not take a password
positionally; the built-in role is "User", not "Users"; and a user added
without a role gets the "User" role.
2026-10-25 21:44:28 +05:30
Ashesh Vashi f88596f90c Reject a backslash or null byte in a schema, table or column name in Import/Export Data. #10663
A schema, table or column name is wrapped with qtIdent() and interpolated
into psql's \copy command. qtIdent() doubles an embedded double quote but
leaves a backslash untouched, while psql's \copy parser (strtokx) treats a
backslash inside a double-quoted identifier as an escape. A name such as
one ending in a backslash therefore becomes "name\", which psql reads as
an unterminated identifier that runs past its intended end, the same
command-injection class (CWE-78) that the query guard already blocks for
query exports. A null byte can truncate the C string so Python's
validation and psql's parse disagree. The column lists reach the \copy
command's FORCE_* options even for a query export, so they are affected
too.

Reject a backslash or null byte in any of these names outright, as the
query path does; pgAdmin never needs to name an object containing one.
The check inspects the names columns_to_string() would pass to qtIdent()
whatever JSON type they arrive as (a list's items, a dict's keys, or a
bare string iterated character by character), so a column value sent as a
string or object cannot slip a backslash through.
2026-10-25 19:52:48 +05:30
Ashesh Vashi 602c3c165f chore: Update third-party dependencies and fix two feature-test helper issues.
JavaScript: @tanstack/react-query, @tanstack/react-table, dompurify,
lossless-json, rc-dock, react, react-dom, react-dropzone,
react-virtualized-auto-sizer, html-react-parser, zustand,
@mui/x-date-pickers, core-js, webpack, postcss, typescript-eslint,
eslint-plugin-jest, globals, @testing-library/user-event.

The postcss resolution override is raised along with the direct
dependency, so installs actually resolve to 8.5.31.

Python (python_version > '3.9' rows only): Authlib, certifi,
Flask-SocketIO, urllib3 and selenium.

Held back: @tanstack/react-virtual (3.14.13) and marked (18.0.14) break
the Jest suite in later releases, and pickr stays at ~1.9.1 because
1.10.x breaks the production bundle.

Feature tests:
- Backup and Restore clicked their Tools menu item while it was still
  disabled, because the menus are refreshed asynchronously after a
  database gets connected. Wait for the item to be enabled first.
- check_if_element_exists_with_scroll() never terminated when it reached
  the bottom of the tree and the node it looked for was above the visible
  area. Stop scrolling down at that point.
2026-10-25 17:49:29 +05:30
Ashesh Vashi d25a38fe62 Give users added by setup.py the User role when no role is given. #10675
add-user and add-external-user defaulted --role to None, so without
--role or --admin they looked up a role named None and exited with
"Role 'None' does not exists." Default --role to 'User', the role
pgAdmin gives every user it creates itself. --admin still takes
precedence, and an unknown --role still fails.
2026-10-25 16:10:08 +05:30
Ashesh Vashi d92fb68b22 Make the LDAP login scenarios independent of each other and of their order. #10675
The 'LDAP Authentication with Auto Create User' scenario logged in
ldap_user, creating an LDAP account that was never deleted, and the
'without Auto Create User' scenario only passed because that account was
already there; run on its own it failed.

The 'without' scenario now adds the LDAP account it logs in to with
add_user(), which deletes it with the rows the login created when the
test ends, and every auto-create scenario deletes the account its login
created.
2026-10-25 16:10:08 +05:30
Ashesh Vashi 2bca0b3cc9 Show the user just added by setup.py add-external-user. #10675
After adding a user, setup.py looked the new account up for display by
its email together with the auth source. For an external user the email
is not the username, so without --email every user of that auth source
was listed, and with --email nothing was shown at all.

Look the account up by the username it was created with and its auth
source instead, and add tests that run setup.py add-user and
add-external-user against a copy of the test database and check that
only the new account is shown.
2026-10-25 16:10:08 +05:30
Ashesh Vashi 2fc4b1f3d4 Fix logins and user management when an account shares its username or email with one from another source. #10675
- Webserver login and auto-creation looked the account up by username
  alone. An internal account with the same username was found first, so
  the Webserver user was refused, or never created.
- LDAP auto-creation, when LDAP_DN_CASE_SENSITIVE is off, compared the
  stored username with the lower-cased login name, so an LDAP user whose
  username has capitals was not found after their first login, and the
  attempt to create them again failed, so they could not log in. It now
  compares both lower-cased.
- setup.py update-user and update-external-user failed with a KeyError
  unless --role or --admin was given, so an account could not be
  deactivated on its own, as the LDAP documentation describes.

A test also checks that a password reset requested with the email in
another case is sent for the internal account.
2026-10-25 16:10:08 +05:30
Ashesh Vashi c6ef41a951 Refuse sign-in, password change and password reset requests that skip pgAdmin's own checks. #10676
Flask-Security registers views of its own next to pgAdmin's: /login,
/verify, /change, /reset and /reset/<token>. Submitting to them logged a
user in without the failed login count or the lock, checked a signed-in
user's password without limit, changed a password without pgAdmin's
change password permission or re-encrypting the saved server passwords,
and sent or applied a password reset for an account that is not
internal or is locked. pgAdmin does all of these through views of its
own, so a submission to Flask-Security's is now refused with 405
Method Not Allowed. A GET still renders the page, as GET /login renders
pgAdmin's login page, whose form is posted to /authenticate/login. The
refusal is not logged as an error, as anyone can send one.

The test uses server mode only views and is excluded in desktop mode.
2026-10-25 16:01:14 +05:30
Ashesh Vashi 35c9235d17 Count every failed internal login, and lock only internal authentication. #10676
The limit on failed logins (MAX_LOGIN_ATTEMPTS) could be got round, and
its lock reached accounts it does not protect:

- The internal account was looked up by its exact username, while
  Flask-Security matches the email ignoring case, so a username typed in
  another case was neither counted nor checked for the lock. It is now
  looked up as Flask-Security does.
- With LDAP or Webserver authentication also configured, the login form
  always passed validation, so a wrong internal password was never
  counted. It is now counted whenever no source authenticates the user
  and internal authentication was tried.
- A login that posts a password no longer falls back to Kerberos, OAuth2
  or Webserver authentication, which do not check it: with them listed,
  a guess was answered by the Kerberos challenge, or silently logged in
  the user the web server named, without being counted.
- The account is locked on the failed login that reaches the limit, and
  the user is told so, rather than at the next login.
- A login through another source no longer resets the internal
  account's failed logins.

The lock is the internal account's. A locked internal account only stops
internal authentication: the other sources are still tried and only ever
log in an account of their own, so a user of another source with the
same name is not locked out by it. The password is not checked against
the locked account. When another source refuses the user, one neutral
message is shown whatever the password, saying a pgAdmin account may be
locked; with no other source to try, the account locked message is shown
as before. The skip is logged.

The new tests use server mode only features and are excluded in desktop
mode. The Kerberos login test now posts no password, as a Kerberos login
does.
2026-10-25 16:01:14 +05:30
Ashesh Vashi 58c531d831 Act only on the internal account when accounts from other sources share its email or username. #10676
The same email or username may belong to accounts from several auth
sources. Flask-Security looks the account up by email alone, and took
whichever row came first:

- Internal login checked the password against that row, so an internal
  user could not log in when an LDAP or OAuth2 account with the same
  email was found first. Flask-Security's lookup now prefers the internal
  account; ignoring case, it prefers the one matching exactly, then the
  oldest. A missing email or username identifies no account.
- A password reset was sent for whichever account had the email, and a
  reset token for an account from another source was accepted, giving it
  a local password and logging it in. Reset instructions are now sent
  for the internal account, and a token for any other account is
  refused.
- The About box showed the configuration to any user whose email was
  that of an administrator's account, whatever its source. It now checks
  the user's own role.

The test runner excluded every test module whose name started with one
on its desktop mode list, so excluding test_reset_password also excluded
test_reset_password_auth_source. Each entry now excludes only the module
it names, and the server mode only modules are listed.
2026-10-25 16:01:14 +05:30
Ashesh Vashi b39f1a8d79 Give the File Manager save tests an absolute path in desktop mode, where there is no storage directory. #10674
In desktop mode get_storage_directory() returns None and a path is
absolute, so check_access_permission() returns it unchanged. These tests
patch in a storage directory in either mode and passed a path relative
to it, which in desktop mode named a file at the root of the filesystem,
so the save failed. They now pass a path inside the storage directory in
desktop mode, as a desktop user would.
2026-10-25 11:43:20 +05:30
Ashesh Vashi d91a2745f0 Run the shared storage write-target tests fully in server mode, so they pass in desktop mode too. #10650
The tests patched the application's SERVER_MODE setting but not
config.SERVER_MODE, which get_locale() reads. In desktop mode the refusal
message's gettext() then took the desktop branch and read
current_user.is_authenticated from the stand-in user, which has no such
attribute, so the "non-admin cannot write" scenario failed. Patch
config.SERVER_MODE as well in the three tests that use this setup.
2026-10-25 11:43:20 +05:30
Ashesh Vashi ad6c028cff Make the File Manager and storage-path tools act on the path that was checked. #10674
In server mode, check_access_permission() validated a path into the
user's storage directory, but several callers then acted on a path built
differently, so the path checked and the path used could name different
files:

- Opening a file in the Query Tool, saving a file, the Google client
  secret and, on Windows, Import/Export Data, Backup and Restore joined
  the path to the storage directory after stripping leading separators,
  so a path checked as inside storage could name any file the pgAdmin
  account can read, or on Windows write outside it.
- Listing a folder decoded the path again after the check, so a folder
  named like an encoded ".." listed any directory on the host.
- Download, delete, rename and the file-exists check appended the path
  to the storage directory without a separator, so where one user's
  storage directory name was a prefix of another's, that user's files
  could be read, deleted or moved; download also checked the path
  appended to itself.
- Creating a folder checked the parent rather than the folder created,
  and upload wrote to a path that was not checked.

check_access_permission() now returns the absolute path it checked, and
every caller acts on that path. Folder paths are decoded once, rename
checks the new name where it is created, and download returns a 404
without the server path for a missing file.

In server mode on POSIX, download, delete, rename and file loads now
open files relative to directories opened from the storage directory
without following symbolic links, as upload and save already did, so a
parent directory swapped for a symbolic link after the check is refused.
The walk follows the path as written: a ".." goes back up past a
directory the walk opened, rather than being collapsed in the text to a
different file from the one the check resolved. It keeps open only the
directories a later ".." can still go back up to, so a path holds at
most two more file descriptors than it has ".." segments, and working
out which to keep takes time linear in the length of the path.

Also:
- The storage directory itself can no longer be deleted.
- In desktop mode, a download refuses a FIFO or other non-regular file
  instead of blocking on it; symbolic links are still followed there.
- A symbolic link refusal is recognised as ELOOP, or EMLINK only where
  O_NOFOLLOW uses it.
- The File Manager sends the selected storage when checking whether a
  file exists, so the overwrite prompt sees files in a shared storage.
- get_closest_parent() tested whether the storage directory existed
  rather than the remembered folder; it now finds the closest existing
  folder inside storage.
- A refused or failed download now shows the server's message.
- check_file_for_bom_and_binary() reports a read error in its result.
2026-10-25 11:03:55 +05:30
Ashesh Vashi 269e32d5ee Ensure Kerberos and internal authentication only log in the account of the user they authenticated. #10675
The same username may exist under several auth sources. Kerberos uses
the shared login(), which looked the account up by username alone, so a
Kerberos principal matching an internal account's username was logged in
as that internal account, including the initial administrator. login()
now looks the account up by the authenticated username and the source
that authenticated it.

form.user is no longer used by any source but internal. Flask-Security's
validation, run for internal authentication, sets it to the account whose
email was typed, whether or not its password matched. With internal
authentication listed first, a Kerberos user could type another Kerberos
user's principal and be logged in as them, and an LDAP user could do the
same with an LDAP account whose email is their username. Internal login
logs in form.user, whose password was verified, only if it is an
internal account, and otherwise nothing.

The LDAP + Internal login test relied on an LDAP bind that accepted the
internal test account's credentials logging in that internal account,
which is the behaviour fixed here. LDAP now refuses those credentials in
that scenario, and new scenarios check that an LDAP login whose username
matches an internal account is not logged in as it, with LDAP or
internal authentication tried first. A further test logs in through the
login endpoint as a Kerberos user who types another Kerberos user's
principal. The LDAP documentation's add-external-user example passed the
source as an argument; it is given with --auth-source.
2026-10-24 19:36:14 +05:30
Dave Page 7378060e5c Ensure LDAP authentication only logs in LDAP-sourced accounts. #10675
LDAPAuthentication.login() looked up the user to log in by username
alone, or accepted the form.user set by Flask-Security's internal
validation, without checking its auth source. Because the same username
may exist under several auth sources, an LDAP user whose username matched
an Internal account (such as the initial administrator created at setup)
was logged in as that Internal account, with its roles.

Only use form.user if it is LDAP-sourced, and restrict the fallback
lookups to auth_source=LDAP, matching the existing guard in Webserver
authentication.

Document that the initial administrator is always an Internal account,
and recommend deactivating it in favour of an LDAP administrator when
using LDAP authentication only.

Reported-by: Yves Soete of Blacksight LLC
2026-10-24 19:36:14 +05:30
Kundan d52fe4740e Stop SSH tunnels using the pgAdmin service account's own SSH identity. #10662
sshtunnel was given neither host_pkey_directories nor ssh_config_file, so it
always loaded the unencrypted private keys in ~/.ssh of the account pgAdmin
runs as, and read that account's ~/.ssh/config (IdentityFile, User, HostName,
ProxyCommand) for the tunnel host. In server mode any user could therefore
configure a tunnel to a host reachable by that account, with no credentials
or an identity file that does not exist in their storage, and authenticate
with the service account's key. The ssh-agent of that account could also be
used.

In server mode the tunnel no longer reads those keys or that configuration,
and does not use the agent. An identity file that cannot be found in the
user's storage is reported instead of silently falling back to ~/.ssh.
Desktop mode is unchanged.

Compatibility: in server mode, SSH tunnels that relied on the service
account's own key, ~/.ssh/config or ssh-agent will stop working; the
identity file or password must be configured on the server instead.
2026-10-24 19:36:14 +05:30
hiteshjambhale 67eab1ba89 Invalidate a user's other sessions when their password is changed. #10646
Rotate fs_uniquifier on a password change (self-service and admin reset), so
every other session of that user is logged out. The changing session is
re-logged in, and its database connections are kept.

Re-encrypt saved passwords, including shared-server ones, under the new key.
Passwords that can't be decrypted are cleared, and the user is told. If
re-encryption fails, the sessions are still revoked and all saved passwords
are cleared rather than returning an error.

Reported-by: Zohaib
2026-10-24 19:36:13 +05:30
Kundan 9e95d9a721 Reject SHARED_STORAGE paths in the utility binary-path guard. #10656
does_utility_exist() and /misc/validate_binary_path only rejected a
binary path under STORAGE_DIR, so a user could point the binary path of
a server type at a SHARED_STORAGE directory that ordinary users can
write to, and have pgAdmin execute whatever file was placed there
(CWE-426).

The check is now made by is_reserved_storage_path(), shared by both call
sites and extended to every SHARED_STORAGE entry. It expands $DIR and
collapses '..' before resolving symlinks, so it judges the directory the
utilities actually run from, compares directories with samefile() so
that a different spelling on a case-insensitive filesystem cannot get
past it, and refuses a path containing a NUL. An empty STORAGE_DIR is
treated as unreserved instead of being resolved against the working
directory. The guard is enforced inside get_binary_path_versions(), so
no caller can skip it, and the PSQL tool now goes through
does_utility_exist() too.

Reported-by: Harsh Singh
2026-10-24 19:36:13 +05:30
hiteshjambhaleandDave Page 9be31a019f Reject a backslash in a double-quoted identifier in the Import/Export Data query export. #10663
_is_query_parens_balanced() rejected a backslash inside a single-quoted
string but not inside a double-quoted identifier, where it treated the
next quote as closing the token. psql does not: strtokx in
parse_slash_copy takes its escape character from the connection's
standard_conforming_strings setting and applies it to whichever quote
opened the token, so with standard_conforming_strings off the \" in
"a\"b" is an escaped quote and the identifier runs on to the next quote.

That desynchronises our quote state from psql's in the opposite
direction to the case fixed in 1496fabe2, and a query export such as
SELECT "a\"b"c) TO PROGRAM 'cmd' x" therefore passed the balance check
whilst the ) it hid closed the wrapping \copy (...) context, exposing a
live TO PROGRAM clause. psql calls popen() for a client-side
\copy ... TO PROGRAM before the backend sees the statement at all, so
the command runs on the pgAdmin host even though the query is then
rejected as a syntax error. An ordinary PostgreSQL role can set
standard_conforming_strings on itself, and a non-administrator pgAdmin
account holds tools_import_export_data by default, so no privileged
access of either kind is needed.

Apply the same treatment as the single-quoted branch and reject any
backslash encountered whilst scanning a double-quoted identifier, since
the correct interpretation still depends on a server setting we cannot
reliably know at validation time.

Co-authored-by: Dave Page <dpage@pgadmin.org>
Reported-by: Yonghwa Lee of Xint by Theori
2026-10-24 19:36:13 +05:30
145e511f05 Stop libpq using the pgAdmin service account's Kerberos credentials in server mode. #10655
When a connection was not meant to use a Kerberos user's delegated
credentials, pgAdmin removed KRB5CCNAME from its environment, so the
Kerberos library fell back to the default credential cache of the account
pgAdmin runs as. libpq then answered any server that asked for GSSAPI
authentication with that account's ticket, whatever user name the
pgAdmin user gave, so any user could connect as the service account's
principal; gssencmode=disable does not prevent this. A client keytab for
that account had the same effect, even with no cache, as the library
fills the cache from it. On Windows, SSPI used the service account's
logon in the same way. psql was also started with a copy of pgAdmin's
environment, which could hold another user's credential cache whilst that
user was connecting, and the connection made to list a publisher's
publications was not made under the lock that guards it.

In server mode KRB5CCNAME and KRB5_CLIENT_KTNAME now always point at a
file that cannot exist, except whilst a user who logged in with Kerberos
connects to a server with Kerberos authentication enabled, when
KRB5CCNAME is that user's delegated cache as before. Because environment
isolation alone does not stop SSPI or gssproxy, every other connection
also gets require_auth=!gss,!sspi and gssencmode=disable, merged with the
server's own require_auth, which is refused if it allowed only GSS or
SSPI. A Kerberos-allowed connection forces gsslib=gssapi, so libpq uses
the delegated cache rather than the Windows logon. psql and the utilities
receive the same settings as PGREQUIREAUTH, PGGSSENCMODE and PGGSSLIB;
background processes now pass the server's own kerberos_conn instead of
always assuming a Kerberos connection, and the publication lookup
connects under the lock. Desktop mode is unchanged.

The forced require_auth, gssencmode and gsslib are serialised last, so
they win over any value a crafted connection parameter key places before
them, and the final connection string is parsed and refused if any of
them differs from the value forced (or is present where it is left to
the environment), so a smuggled value cannot steer the PSQL tool or a
utility to GSSAPI or SSPI however the parameters were serialised.

A pg_service.conf entry ranks above the PG* environment variables, so
only a service the administrator lists in SERVER_MODE_ALLOWED_SERVICES
can override these settings for the utilities; the documentation says
such an entry must not set them.

Compatibility: in server mode, connecting with the service account's
Kerberos ticket, client keytab or Windows logon (SSPI) no longer works;
only users who log in to pgAdmin with Kerberos can use GSSAPI, with their
own delegated credentials, as the documentation already described. A
server whose require_auth lists only gss or sspi is refused unless the
user logged in with Kerberos (SSPI-only is never usable). require_auth
needs libpq 16 or later; with an older libpq only the KRB5CCNAME and
KRB5_CLIENT_KTNAME isolation applies, which does not cover Windows SSPI
or gssproxy.

Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
2026-10-24 19:36:12 +05:30
a0c8f4857f Restrict connection services to an administrator's allowlist in server mode. #10654
libpq looks a service up in the connection service file of the account
pgAdmin runs as, and an entry there can hold a password or name a
password file or client certificate. In server mode any user could set a
server's service, or a 'service' connection parameter, to one of those
entries and connect with its credentials. Likewise, a PGPASSWORD or
PGSERVICE variable set for that account was used by libpq for every
connection that had no value of its own, and passed on to the utilities.

In server mode a service may now only be used if it is listed in the new
SERVER_MODE_ALLOWED_SERVICES setting, which is empty by default. Creating
or updating a server, an ad hoc workspace connection or a servers.json
import that names any other service is refused, and connecting to an
existing server, or running psql or a utility for it, fails with a
message that names the setting. The 'servicefile' connection parameter is
refused in server mode, and PGPASSWORD and PGSERVICE are removed from
pgAdmin's environment at startup, with a warning. PGSERVICEFILE and
PGSYSCONFDIR are kept, since they locate the listed services. Desktop
mode is unchanged.

The allowlist is checked again on the final connection string, as libpq
will parse it, so a service smuggled in through a crafted parameter key
is refused however the parameters were serialised. pg_dumpall expands a
--database value containing "=" as a connection string, so the
maintenance database name of Backup Server and Globals is wrapped in one
and a name such as "service=name" no longer selects a service from the
service account's service file. client_files_isolated() treats a missing
PGADMIN_RUNTIME as "not the desktop runtime", so "setup.py load-servers"
no longer fails on any servers.json containing a Service and still
enforces the allowlist in server mode.

The tests run the real job-creation path of every tool that starts a
utility, and check that a service that is not listed is refused before
the utility starts, a listed one is exported as PGSERVICE, and a service
or servicefile connection parameter, or a database name that looks like
a connection string, never selects a service.

Compatibility: in server mode, servers that use a service stop
connecting until the administrator lists it in
SERVER_MODE_ALLOWED_SERVICES, and a PGPASSWORD or PGSERVICE set in the
environment pgAdmin starts with is ignored. "setup.py load-servers" now
refuses a servers.json whose Service is not in
SERVER_MODE_ALLOWED_SERVICES (exit status 1) instead of printing an
error and exiting 0.

Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
2026-10-24 19:36:12 +05:30
KundanandDave Page 0eefa38f77 Stop leaving server passwords in pgAdmin's own process environment. #10653
To pass a server's password to psql and to the utilities run as background
processes, ServerManager.export_password_env() wrote it into os.environ:
as PGPASSWORD for psql, and under the process ID for a utility. Nothing
removed it again, and pgAdmin's environment is shared by every user's
requests and copied into every process it starts, so another user's later
psql session or utility inherited the password, and libpq read PGPASSWORD
for any connection made without one of its own. In server mode that let
one user connect with another user's saved password.

The password now goes only into the environment built for the process
that needs it: export_password_env() fills in a dict it is given, the
background process adds it to the environment of that process alone, and
psql is started with a copy of pgAdmin's environment plus its password,
rather than with os.environ itself, which get_user_env() also modified by
setting the user's psqlrc and history paths in it. The process executor
removes the variable it reads the password from, so the utility only
sees PGPASSWORD.

There is no compatibility impact: psql and the utilities receive the same
password in the same variable as before.

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-24 19:36:12 +05:30
KundanandDave Page 32db693198 Stop libpq using the pgAdmin service account's own client files in server mode. #10652
When a server had no password file, client certificate or client key
configured, or the configured one could not be found, pgAdmin left the
parameter out of the connection and libpq fell back to ~/.pgpass,
~/.postgresql/postgresql.crt and ~/.postgresql/postgresql.key (or
PGPASSFILE, PGSSLCERT and PGSSLKEY) belonging to the account pgAdmin runs
as. In server mode that let any user connect with the service account's
saved passwords or client certificate, and supplying a copy of its public
certificate was enough for libpq to pair it with the service account's
private key.

In server mode each of these parameters is now always given a value:
the user's own file if one is configured, otherwise a path that can never
be opened, so libpq neither uses nor looks for a default. This applies to
the connection string, to the PGPASSFILE, PGSSLCERT and PGSSLKEY variables
passed to the utilities, and to the direct connection made to list a
publisher's publications. A configured file that does not exist is now
passed on as configured instead of being dropped, so a missing root
certificate or CRL no longer quietly falls back to root.crt or root.crl,
and a CRL directory is now passed on in server mode. The trust settings
(sslrootcert, sslcrl, sslcrldir) are still left to libpq when they are not
configured, as they hold no secret, and "system" is no longer mistaken for
a file name in the subscription connection. Desktop mode is unchanged.

libpq ranks a pg_service.conf entry above the PG* environment variables,
so a service entry that set passfile, sslcert or sslkey still won for
pg_dump, pg_restore, psql and the maintenance tools. In server mode the
three settings are therefore also passed in the --dbname connection
string, which libpq ranks above the service file; pg_dumpall takes them
in a separate --dbname argument so that its per-database pg_dump
processes inherit them. pg_dumpall's --database, the maintenance database
name passed raw, is now wrapped as a connection string too, so a name
such as "passfile=..." cannot bring the service account's files back. The
file settings are chosen from the same source as the environment
variables, so that the two cannot disagree.

Compatibility: server mode deployments that relied on the service
account's ~/.pgpass, PGPASSFILE, default client certificate and key,
PGSSLCERT or PGSSLKEY, or on a passfile, sslcert or sslkey in a
pg_service.conf entry, will need to configure those files on each server
instead. In server mode a pg_dump, pg_restore or psql older than
PostgreSQL 10 fails on the passfile keyword, and a server configured with
an sslcert but no sslkey now fails with "certificate present, but not
private key file" instead of silently using the service account's key.
The container is not affected, as its entrypoint already copies
PGPASS_FILE into the user's storage and sets the password file
explicitly. Passwords in a pg_service.conf entry chosen through the
service setting are not addressed here.

The tests now cover a server whose user password file is held only on
the server row. The auth gating test restores the provider modules it
re-imports, so later OAuth2 tests no longer fail depending on test
order.

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-24 19:36:12 +05:30
KundanandDave Page d761942d0b Restrict sharing a server to administrators in the server API and the servers.json import. #10651
The server dialog only shows the Shared switch to administrators, and
the servers.json import only honoured it for them, but the create and
update endpoints stored whatever 'shared' and 'shared_username' they
were sent. Any user able to register a server could therefore share one
with every other user of a server mode installation. The import had a
similar gap: validate_json_data() removed a non-administrator's "Shared"
entries only after its loop, so an early validation error left them in
the data, and the web path ignored that error, so selecting a shared
entry in a file that also held an invalid one created a shared server.

A non-administrator's request that would share a server is now refused
with a 403. A 'shared' value that changes nothing is still accepted, so
editing a private server is unaffected, and 'shared_username' is dropped
rather than refused because the dialog sends it whenever the username
changes. The import prunes a non-administrator's shared entries before
validating and skips them again for each imported entry, only a JSON
true counts as shared, and validation errors are reported on the web
path too. Copy Server no longer sends shared for a non-administrator
owner.

The existing servers are cleared for "replace" only after the file has
validated, so a refused file no longer deletes them. Servers shared by
non-administrators before the upgrade stay shared until the owner
un-shares them or an administrator changes them; pgAdmin warns about
them at server-mode startup, and the documentation describes this.

Compatibility: a servers.json import in server mode that fails
validation is now refused as a whole on the web path, as it already was
in setup.py.

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-24 19:36:12 +05:30
2afe505ef4 Prevent connection file paths and parameter keys escaping the user's storage directory. #10650
In server mode, get_complete_file_path() joined the user's storage
directory with the path they supplied after stripping only leading
slashes, so a password file, SSL certificate, key, CRL or SSH tunnel
identity file set to "../<another user>/.pgpass" (through the server
dialog, the API or a servers.json import) would connect with another
user's credentials or key. The sslkeylogfile parameter was copied into
the connection string unchanged, so a traversal path let libpq create or
append a file as the pgAdmin service account.

The resolved path, with symbolic links followed, must now stay inside
the user's storage directory, or inside the named shared storage
directory for a "Name:/file" path, as the file manager already requires.
sslkeylogfile is confined the same way, is refused in a shared storage
directory that is read-only for the user, and is no longer inherited by
non-owners of a shared server. A path that escapes, or whose storage
directory cannot be determined, raises PermissionError rather than
letting libpq fall back to its default file, and the message says where
the file has to live. Desktop mode is unchanged.

make_conninfo() quotes connection-parameter values but not their keys,
so a key such as 'sslmode=require passfile' smuggled its own
keyword=value pairs past these checks. Only exact libpq keywords, read
from the linked libpq at runtime, are now accepted as connection
parameter keys: when a server is created, updated, imported from
servers.json or used for an ad-hoc connection, and again when the
connection string is built, for rows saved earlier.

Connecting, the SSH tunnel and the utilities' job creation report a
refused path or key instead of proceeding, and a job whose environment
cannot be resolved is removed rather than left in the Processes list. A
server with a refused path or key can still be listed and edited to
correct it. On Windows, the short path of a not-yet-existing file is
resolved via its parent directory instead of being dropped.

Co-authored-by: Dave Page <dpage@pgadmin.org>
Co-authored-by: hiteshjambhale <hitesh.jambhale@enterprisedb.com>
2026-10-24 19:36:12 +05:30
hiteshjambhale 730d1e783c Refuse hard links and FIFOs in the symlink-race fixes, and stop leaking storage paths in errors. #10649
Follow-up to the Import/Export Servers, File Manager and utility-file
symlink-race fixes.

- Hard links: O_NOFOLLOW refuses only symbolic links, so a second name
  for a file outside the storage directory was still opened.
  _open_upload_target(), _open_read_target() and open_utility_file() now
  refuse a file whose link count is not 1, reporting a file unlinked
  after the open separately from one with several links. For writes,
  O_TRUNC is applied only after that check (via ftruncate), so a
  hard-linked file is never emptied before it is refused.

- FIFOs on write: _open_upload_target() opens non-blocking and accepts
  only a regular file, so a FIFO planted at the path can no longer hang
  an upload, save or export waiting for a reader. _open_beneath()
  reports the ENXIO of a reader-less FIFO as "Not a regular file", so
  Backup, Restore and Import/Export Data get the same message.

- Errors: the Import/Export Servers, save_file, Backup, Restore and
  Import/Export Data handlers return the reason (strerror) rather than
  str(e), which included the absolute storage path, and fall back to the
  exception's text for errors without an errno, so a non-UTF-8
  servers.json is reported instead of failing with a 500.
  load_database_servers() keeps the file path in its messages only for
  the setup.py CLI, and the upload response returns the
  storage-relative name instead of the on-disk path.

Tests cover the hard-link refusal (target left intact), reader-less
FIFOs on the upload and utility-write paths, the non-UTF-8 servers.json
and the handlers' path-free errors.
2026-10-24 19:36:11 +05:30
hiteshjambhale c4321b9968 Fix symlink races in the files handed to Backup, Restore and Import/Export Data. #10648
Backup, Restore and Import/Export Data checked the user's file path
against their storage directory with filename_with_file_manager_path()
and then passed the path to pg_dump, pg_dumpall, pg_restore or psql,
which opened it by name later. A symbolic link swapped in at the leaf or
a parent directory in between redirected the utility outside the storage
directory, so a backup could overwrite, or a restore or \copy read,
another file that pgAdmin's OS user can reach.

In server mode pgAdmin now opens the file itself through the File
Manager's descriptor-relative O_NOFOLLOW walk from the storage directory,
accepts only a regular file, and hands the utility /dev/fd/N.
BatchProcess.start() gains pass_fds and launch-only arguments so the
descriptor is inherited by the process executor and the utility, whilst
the job details stored and shown to the user keep the real path.

Descriptors are only passed on Linux; on macOS and the BSDs the file is
still opened and checked safely but the utility reopens it by name
afterwards. Desktop mode and Windows are unchanged.
2026-10-24 19:36:11 +05:30
hiteshjambhale 99f5606e2e Fix symlink races in Import/Export Servers and File Manager file opens. #10647
dump_database_servers() validated the servers.json path against the
user's storage directory with filename_with_file_manager_path(), then
wrote it with a plain open(), so a symbolic link swapped in at the leaf,
or at any parent directory of it, between the check and the write let the
export overwrite a file outside the storage directory.
load_database_servers() and the import file-listing endpoint had the same
gap on the read side.

A new _open_beneath() opens the storage directory and then walks down to
the file one component at a time, each open relative to the descriptor of
the directory above it and made with O_NOFOLLOW, so the kernel refuses a
symbolic link wherever it appears below the storage root. New files are
created 0o600, and reads accept only a regular file, opened non-blocking
so that a FIFO cannot hang the request. Desktop mode, Windows and the
setup.py CLI path are unchanged.

Reported-by: MUTED CYBERSECURITY S.L.
2026-10-24 19:36:11 +05:30
hiteshjambhale a1a43e8c28 Stop the materialized view refresh redirecting its connection via a crafted database name. #10645
MViewNode.refresh_data() passed the target database's name straight to
psql's --dbname argument. libpq treats a --dbname value containing "="
(or a postgresql:// URI) as a full connection string, so a database
named e.g. 'host=attacker port=5432 dbname=postgres' - creatable by any
role with only CREATEDB - redirects the spawned psql to an attacker host,
leaking the connecting user's saved server password.

Route the name through database_conninfo(), the same helper already used
for backup, restore, maintenance and import/export, so libpq treats it
strictly as a database name and never as connection directions.

Reported-by: Bae Seung-won of Ajou University
2026-10-24 19:36:11 +05:30
Kundan ea293b335e Fix the Statistics node failing to list its children in Object Explorer. #10310 (#10639)
pgAdmin renders its templates with trim_blocks, which removes the newline
after a block tag. nodes.sql ended its WHERE clause with a conditional, so
with neither a statistics id nor schema_diff set, which is what expanding
the Statistics collection sends, the SQL ran into "::oidORDER BY" and the
server rejected it.

Give each condition its own line ending in plain text, and add a test that
renders the template for every parameter combination and runs it.
2026-10-22 18:18:27 +05:30
dependabot[bot] a623526cb7 Javascript dependency: Bump electron-store in /runtime (#10627)
Bumps [electron-store](https://github.com/sindresorhus/electron-store) from 11.0.2 to 12.0.0.
- [Release notes](https://github.com/sindresorhus/electron-store/releases)
- [Commits](https://github.com/sindresorhus/electron-store/compare/v11.0.2...v12.0.0)

---
updated-dependencies:
- dependency-name: electron-store
  dependency-version: 12.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 13:30:16 +02:00
dependabot[bot] 39aed6083b Javascript dependency: Bump electron (#10626)
Bumps the runtime-javascript-minor-and-patch group in /runtime with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 44.5.1 to 44.7.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.5.1...v44.7.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: runtime-javascript-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 13:29:52 +02:00
dependabot[bot] 05b2864d32 Python dependency: Update flask-socketio requirement (#10628)
Updates the requirements on [flask-socketio](https://code.miguelgrinberg.com/miguelgrinberg/flask-socketio) to permit the latest version.

---
updated-dependencies:
- dependency-name: flask-socketio
  dependency-version: 5.7.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 13:29:19 +02:00
dependabot[bot] 8c5dd5eadb Javascript dependency: Bump follow-redirects in /web (#10638)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.1.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 13:29:15 +02:00
dependabot[bot] b345337ddc Javascript dependency: Bump follow-redirects in /runtime (#10637)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.1.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 13:29:10 +02:00
Dave Page b8afe34fbb Keep external pages out of the desktop runtime's privileged windows and confine non-prompted downloads to the downloads directory. #10614 (#10616) 2026-10-16 09:38:54 +01:00
Dave Page 7f21dc4610 Reconnect a lost Query Tool connection as the user and role it was changed to. #10613 (#10622) 2026-10-16 03:21:58 +01:00
Dave Page ac76a81ad2 Revert the Query Tool execute option checkboxes when changing an option fails. #10600 (#10621) 2026-10-16 02:29:02 +01:00
Dave Page 5f9909e109 Free Query Tool autocomplete objects when an idle session's connections are released. #10612 (#10625) 2026-10-16 02:26:16 +01:00
Dave Page 31a5c5418c Keep the Query Tool execute options when its transaction is re-created. #10598 (#10599) 2026-10-16 01:07:38 +01:00
Dave Page cc1e1ef184 Remove the dead tree 'loadfail' handler from browser.js. #10601 (#10620) 2026-10-16 00:59:08 +01:00
Dave Page 1402f7d9cd Fix the Jest addNewDatagridRow helper so schema UI specs actually add a row. #10602 (#10624) 2026-10-16 00:46:52 +01:00
Dave Page 6594977cc3 Fix Schema Diff treating a function whose input argument was renamed as two unrelated functions. #10597 (#10623) 2026-10-16 00:30:56 +01:00
Dave Page b98470f327 Allow shift + click to extend the cell range selection in the Query Tool results grid. #8647 (#10617) 2026-10-15 22:27:56 +01:00
Dave Page 35307612fc Fix formatting a view's SQL being treated as a column change that drops and re-creates the view. #9838 (#10591) 2026-10-15 22:21:26 +01:00
Dave Page 8b381912a8 Fix Azure and Google authentication in the cloud deployment wizard. #9921 (#10609) 2026-10-15 21:25:00 +01:00
Dave Page ab749c630c Report a failed autocomplete reconnect in the Query Tool instead of ignoring it. #10604 (#10606) 2026-10-15 20:53:26 +01:00
Dave Page 1cac42d986 Keep Query Tool execution disabled until a restored tab's execute options reach the server. #10618 (#10619) 2026-10-15 20:49:10 +01:00
Dave Page f73b51f912 Fix modal dialogs being hidden from assistive technology by aria-hidden on the application root. #9865 (#10587) 2026-10-15 19:40:21 +01:00
Dave Page 733cd5ba5e Allow the emailed MFA code to be re-entered after an invalid code is entered. #6671 (#10589) 2026-10-15 19:30:20 +01:00
Dave Page 8877330705 Fix Query Tool autocomplete objects being shared between sessions that use the same transaction id. #10607 (#10608) 2026-10-15 18:20:52 +01:00
Dave Page 8d8bcf3748 Fix the Query Tool not re-running the query after reconnecting at a password prompt. #10603 (#10605) 2026-10-15 18:02:30 +01:00
Dave Page 7828c9b2a1 Restore the per-tab toolbar options when Query Tool tabs are restored on relaunch. #9603 (#10593) 2026-10-15 17:29:43 +01:00
Dave Page 09afc34c0e Fix Schema Diff treating functions that differ only in OUT arguments or return type as unrelated objects. #6652 (#10595) 2026-10-15 17:23:43 +01:00
Dave Page 2477a94b32 Fix a flaky Jest test for the connection parameter value dropdown. (#10610) 2026-10-15 15:06:27 +01:00
Dave Page bbb5cc250b Connect the server when changing the Query Tool connection after it was disconnected. #8144 (#10588) 2026-10-15 14:50:25 +01:00
Dave Page 33fab95a73 Fix a database's label staying at '[Disconnecting...]' when disconnecting it fails. #9624 (#10590) 2026-10-15 14:49:51 +01:00
Dave Page 755cfec19e Show fill factor, toast tuple target and parallel workers in the table Properties panel. #9212 (#10594) 2026-10-15 12:34:34 +01:00
Dave Page 5711c4dd5a Keep the AI Assistant's intermediate responses instead of replacing them with placeholder messages. #9818 (#10592) 2026-10-15 12:20:18 +01:00
Dave Page a6374d1ccd Re-sort renamed server groups and other nodes in the Object Explorer instead of waiting for a reload. #8015 (#10586) 2026-10-15 11:45:52 +01:00
Dave Page 78afe89752 Fix Query Tool autocomplete running as the wrong user, or failing, after changing the connection to a different user. #7619 (#10582) 2026-10-15 10:42:16 +01:00
Dave Page 70c6777b5e Fix ERD tables becoming immovable after a Shift-drag selection. #9890 (#10581) 2026-10-15 10:38:55 +01:00
Dave Page 7c5b0b7da7 Fix modal dialogs losing keyboard focus to a tool iframe loading in the background. #9784 (#10583) 2026-10-15 10:38:39 +01:00
Dave Page 472965ec40 Fix the server context menu showing database options after the tree state is restored. #7612 (#10580) 2026-10-15 10:38:24 +01:00
Dave Page 610cad0e0e Select the adjacent tab when the active tab is closed, rather than the left-most. #9130 (#10578) 2026-10-15 10:38:10 +01:00
Dave Page 72ab768b94 Fix the parameter value dropdown keeping stale options when the parameter name is changed. #9630 (#10579) 2026-10-15 10:37:52 +01:00
Sanskar Kharya 5063c76a06 Preserve explicit 24-hour mode in date-time picker popups. #10584 (#10596) 2026-10-15 10:36:29 +01:00
Dave Page 607c9eccbf Remove unused Google API documents, Babel locale data and packaging tools from the packages. #9331 (#10576) 2026-10-14 18:20:56 +01:00
Dave Page e8f7a18c98 Allow the login page to be skipped when OAuth2 is the only authentication source. #9975 (#10575) 2026-10-14 17:19:12 +01:00
Dave Page a00cd1dd43 Fix Query Tool state save/restore failing on installations with a legacy SECRET_KEY. #10573 (#10574) 2026-10-14 17:17:46 +01:00
Dave Page b6a4c7fe15 Document running the container with rootless Podman, and the PUID and PGID environment variables. #9524 (#10572)
* Document running the container with rootless Podman, and the PUID and PGID environment variables. #9524

* Note that the U volume option is recursive and does not relabel for SELinux.
2026-10-14 15:44:33 +01:00
Dave Page d699acab0b Clarify the configuration files documentation. #5369 (#10571)
* Document that config_local.py is lost on upgrade on macOS. #5369

* Retitle the config_py docs page to 'Configuration Files', as it covers all four configuration files. #5369
2026-10-14 14:27:01 +01:00
Hari Prasad 5f6d72c686 Add numpad zoom shortcuts to the Desktop View menu. #10503 (#10533) 2026-10-14 13:24:34 +01:00
Dave Page 50209794d7 Require SSL for pgEdge Starfleet hosts and warn when libpq cannot connect to them. (#10569)
Starfleet endpoints drop SSL connections that arrive without SNI, which
libpq only sends from version 14, and never answer a non-SSL connection.
The cloud wizard now warns when pgAdmin's libpq is older than 14, and
the Register Server dialog defaults sslmode to require for *.pgedge.cloud
hosts, since with prefer any failed SSL attempt is retried without SSL
and reported only as a timeout.
2026-10-14 12:57:08 +01:00
Sanskar Kharya 5515b54d53 Move the 'Returns a set?' option to the Definition tab in the Function dialog. #9264 (#10565)
Also update the documentation and screenshots to match.
2026-10-14 12:08:07 +01:00
Dave Page a0b4c09772 Fix server update ignoring the connected-server check, and Connection.reset() never reconnecting. (#10567) 2026-10-14 11:37:03 +01:00
Dave Page 0389ee789a Bundle Python 3.13.16 in the Windows and macOS packages. (#10566)
Python 3.14 has to wait: the Windows build needs pywinpty, which is held
at 2.0.x for andfoy/pywinpty#545, and 2.0.x cannot be built for 3.14.
Note that beside both pins.
2026-10-14 10:45:54 +01:00
Dave Page 26a7d92b0b Fix the Linux packages shipping a stray copy of Python as venv/bin/𝜋thon. #10414 (#10564)
Python 3.14's venv module creates a "𝜋thon" symlink next to python,
python3 and python3.X (CPython gh-119535). The hash bang fixup runs
"sed -i" over venv/bin/*, which replaces every symlink with a regular
copy of its target; the python* links are recreated afterwards, but the
new alias was not, so packages built against Python 3.14 shipped a full
copy of the interpreter under that name, and dpkg on Ubuntu 26.04 fails
to unpack it. Nothing uses the alias, so remove it when the venv is
created.
2026-10-13 14:56:32 +01:00
Sanskar Kharya e02150e634 Fix the Dashboard Logs tab failing on multi-byte characters split across read chunks. #10511 (#10526)
The Logs tab reads the current log file in fixed-size byte ranges with
pg_read_file(), which raises 'invalid byte sequence' (SQLSTATE 22021)
when a range boundary falls inside a multi-byte character, so Tabular
format failed on logs containing non-ASCII text. The ranges are now read
with pg_read_binary_file(), transported as hex and decoded in pgAdmin
using the server encoding. Tabular reads fetch a few bytes of overlap so
a character split at a page boundary is shown whole on one page, and
genuinely invalid bytes are shown as replacement characters rather than
raising an error. For the EUC family of server encodings a page boundary
can still garble one character, as lead and trail bytes share ranges.

- Keep valid text after an invalid byte near the end of the file, trimming only a character still being written.
- Treat a truncated character in the CJK codecs as incomplete rather than invalid.
- Correct the encoding limitation comment, as GBK and GB18030 are not server encodings.
2026-10-13 13:23:44 +01:00
Dave Page 3e6f0c0703 Make server tests restore the shared test fixture after themselves. #10553 (#10562)
Several tests changed the server dict that the runner shares between all
tests (shared, service, SSH tunnel settings and so on) and never put it
back, so later tests inherited those settings; running the whole server
test package locally made the non-owner tests in
test_server_data_isolation.py fail with HTTP 428.

Move restore_server_fixture_on_cleanup() into servers/tests/utils.py and
call it from every test that modifies the fixture, including the sqleditor
new connection tests that added a password key, and stop the SSH mock
connect test relying on a tunnel_password key leaked by test_add_server.
2026-10-13 13:14:19 +01:00
Dave Page 1b05065eb0 Fix changing an identity column's START value also restarting its sequence. #10550 (#10561)
The column ALTER emitted RESTART SET START whenever START changed,
rewinding the identity sequence so the next generated value could
collide with existing rows; Schema Diff hit this too when only START
differed. RESTART is now emitted only when the sequence is still pending
at its old START with nothing generated since, or when its current value
would fall outside a new MINVALUE or MAXVALUE (which PostgreSQL refuses
without it). To decide, the column update reads last_value and is_called
from the sequence itself when START is set, since
pg_sequence_last_value() is NULL after any RESTART WITH.
2026-10-13 12:57:17 +01:00
Dave Page 8f24dc378d Add a test for Reset discarding pending SQL editor values in dialogs. #10552 (#10560)
The fix for #10357 remounts the dialog form on Reset so that SQL editor
values still waiting to come back through SchemaView are discarded. This
spec types into an SQL field, removes the edit, types again and presses
Reset before SchemaView has passed those values back, then checks that the
original value is shown. Without the remount the editor keeps the last
typed value.
2026-10-13 12:11:02 +01:00
Dave Page f4337dc8c7 Fix the cursor jumping to the start of SQL fields in dialogs after a quick edit and undo. #10551 (#10559)
When an edit and its undo (or a key, Backspace and the same key again) were
all reported before SchemaView passed the first value back, the editor's
pending values held the same text twice. Matching the newest copy dropped the
value in between, which then replaced the document when it came back. Values
come back in order, so InputSQL now matches the earliest copy; if some are
skipped, the leftover entries are cleared by later changes.
2026-10-13 12:10:01 +01:00
Dave Page fd4b47788b Fix column properties reporting an arbitrary parent for columns merged from several parents. #10554 (#10563)
The inherited-columns CTE joins on column name, so a column merged from
more than one parent produced a row per parent, and DISTINCT ON then
kept whichever came first. Order by the inheritance sequence number so
the first parent in inheritance order is always reported.
2026-10-13 12:07:32 +01:00
Dave Page 74003c19dc Fix Schema Diff not removing a column comment that exists only in the target. #10549 (#10556)
A source column without a comment has a description of None, which the
column update template skips, so the comment was reported as a difference
but never dropped. Send an empty string in that case, as the column dialog
does when a comment is cleared, so COMMENT ON COLUMN ... IS '' is
generated.
2026-10-13 12:06:01 +01:00
Dave Page a11fc0b921 Fix valid escapes below U+0100 being rewritten in SQL_ASCII data and names. #10547 (#10558)
The SQL_ASCII codec unescaped every valid \uXXXX and \UXXXXXXXX
sequence, so a value or column name containing '\u0041' was shown as
'A', and saving the row wrote 'A' back.

Decode each byte as latin-1 and only unescape escapes for code points
from U+0100 to U+10FFFF, which are the only ones pgAdmin writes when
encoding. Lower escapes and invalid sequences are kept as their literal
bytes. Encoding is unchanged.

- Decode long runs of backslashes in linear time in the SQL_ASCII codec.
2026-10-13 12:02:55 +01:00
Dave Page 5811f55b08 Fix negative identity column options being dropped from the generated SQL. #10548 (#10557)
The column and table templates only emitted an identity option when its
value was greater than -1, which was meant to skip unset options but
also dropped every negative one. So on a descending identity column,
changing INCREMENT to -2 in the column dialog or a MINVALUE difference
in Schema Diff produced no ALTER at all, and the table's CREATE SQL lost
its INCREMENT, START, MINVALUE and MAXVALUE. Test for an unset option
explicitly instead.
2026-10-13 12:02:05 +01:00
Dave Page d1374536d8 Fix flaky test_windows_pty_failures counting psql emits from other tests. #10546 (#10555)
The tests patch psql.sio.emit globally, so a psql session from another
test that is still running can emit pty-output into the same mock. Only
count the emit calls sent to the test's own room.
2026-10-13 12:01:47 +01:00
Dave Page 04684e798a Fix View/Edit Data failing on SQL_ASCII databases when a name contains a backslash followed by 'u' or 'U'. #5940 (#10545)
pgAdmin uses raw_unicode_escape as the Python codec for SQL_ASCII
connections, and psycopg decodes column names and query text with it.
A name such as "C:\User\te" contains '\U' that is not a valid
\UXXXXXXXX escape, so decoding raised "truncated \UXXXXXXXX escape".

Register a pgAdmin codec that behaves like raw_unicode_escape but keeps
invalid escape sequences as their literal bytes, and use it for
SQL_ASCII. Encoding is unchanged.
- Give the SQL_ASCII codec incremental support, and keep invalid escapes in server errors.
2026-10-13 08:18:24 +01:00
Dave Page 8df5306ebd Fix debugger messages from successive steps running together on one line. #8664 (#10544)
Each poll of the debugger returns its own batch of notices without a
trailing newline, so appending them directly ran messages from separate
steps together. Start each appended batch on a new line.
- Clarify that debugger message batches only usually lack a trailing newline.
2026-10-13 08:03:35 +01:00
Dave Page ab632646ed Fix the cursor jumping to the start of SQL fields in dialogs when keys are pressed together. #10357 (#10543)
SchemaView passes a field's value back a render or two after the change,
so when two keys are pressed almost at once the SQL editor could be handed
the value from the first key whilst already holding the second. Applying
that older value replaced the whole document, which moved the cursor to
the start. InputSQL now remembers the values the editor has reported and
ignores one that is older than the editor's current content.
- Remount the dialog form on Reset so pending SQL editor values are discarded.
2026-10-13 07:07:43 +01:00
Dave Page 43d1d59e00 Fix Schema Diff reporting identical tables as different when a foreign key's referenced table has a dropped column. #10446 (#10542)
pg_constraint.confkey holds the attribute numbers of a foreign key's
referenced columns, which differ between databases when a column has been
dropped from the referenced table in only one of them. The referenced
columns are already compared by name, so ignore confkey as conkey already
is.

A foreign key whose referenced columns differ is now dropped and
recreated, rather than being reported as different with an empty diff.
2026-10-13 06:31:01 +01:00
Dave Page 149170195d Fix the generated SQL to use a single GRANT statement for roles with identical privileges. #6231 (#10541)
parse_priv_to_db() now merges ACL entries that grant exactly the same
privileges, from the same grantor, into one entry listing every grantee,
so the templates for all object types produce
'GRANT ... TO role1, role2;' rather than one statement per role.

A privilege whose grantee is changed away from PUBLIC now also revokes
PUBLIC's privileges, rather than leaving them in place.
2026-10-13 05:36:19 +01:00
Dave Page e0a2d2138e Fix Schema Diff repeating unchanged column comments and identity options. #10422 (#10540)
Schema Diff passes a changed column with all of its properties, but the
column update template emitted COMMENT ON COLUMN and every identity
sequence option without comparing them with the column as it stands.
So any difference in an identity column repeated its unchanged comment,
and repeated START as RESTART SET START, which rewinds the identity
sequence on the target. Only emit those that actually differ.
2026-10-13 05:15:06 +01:00
Dave Page b10718d616 Fix Reset Layout dropping Query Tool tabs from the saved application state. #10424 (#10539)
resetLayout() re-opened the non-default tabs without their metaData, so
the layout saved straight afterwards stored them as bare ids and loadTab()
had nothing to rebuild them from on the next load. Their group is now
carried over as well, rather than falling back to 'default'.
2026-10-13 04:13:55 +01:00
Dave Page 3ec0aafa36 Fix UNLOGGED handling for partitioned tables and their partitions. #9057, #9836 (#10538)
PostgreSQL 18 rejects UNLOGGED on a partitioned table, in both CREATE
TABLE and ALTER TABLE ... SET UNLOGGED/LOGGED, so on PG18 and later
the Unlogged switch is now disabled for partitioned tables (including
a partitioned partition) and the generated CREATE, ALTER and reverse
engineered SQL no longer include it. Leaf partitions can still be
unlogged, and older servers are unchanged.

The Partitions SQL section of a partitioned table's script also now
keeps UNLOGGED for partitions that are unlogged, which it previously
dropped.
2026-10-13 04:03:21 +01:00
Dave Page 6a90e4f837 Remove the dead usr key from sqleditor.connect_server URLs. #10418 (#10536)
The route has no user placeholder, so url_for() always dropped it and
the backend never read it; the now-unused user argument is removed
from the connect-server helpers too.
2026-10-13 02:44:28 +01:00
Dave Page ccdb1f6271 Fix the Query Tool saving files that were loaded as latin-1 back as UTF-8. #10531 (#10537)
The latin-1 read fallback now records the encoding, and encodings are
tracked per resolved file path rather than by basename, and only after
a save succeeds.
2026-10-13 02:35:01 +01:00
Dave Page 3d5661ed87 Refresh a stale ServerManager when a server id is reused. #6090 (#10312)
A cached manager whose server definition no longer matches is rebuilt,
and the owner's settings are deep-copied before being overlaid for
shared-server users.
2026-10-13 02:14:07 +01:00
Dave Page 6404865bd5 Restore SCHEMA_VERSION 53, reverted by mistake when squashing #10330.
The #10330 branch added and then dropped a schema version bump; replayed
on top of #10506, which also took version 53, the squash set it back to
52 even though the server_passexec_name migration remains.
2026-10-13 01:26:42 +01:00
Dave Page e28b84ed67 Restore tags for standard users on shared servers, and never inherit the owner's passfile. #10136 (#10330)
Tags are copied to a user's shared server entry, whilst passfile is
treated like passwords, SSH keys and SSL client files and is never
copied from or overlaid with the owner's value.
2026-10-13 01:26:15 +01:00
Dave Page fc12b2e36b Pass every supported connection parameter to the utilities. (#10450)
Connection parameters with a libpq environment variable are exported to
pg_dump, pg_restore, psql and the other utilities, PGSERVICE comes from
the per-user server manager, and boolean parameters stored as strings
are normalised consistently with the main connection path.
2026-10-13 01:21:49 +01:00
Dave Page 34908c4093 Spawn psql directly on Windows instead of via cmd.exe. (#10453)
psql is launched directly through pywinpty, read errors end the session
cleanly, spawn failures are reported to the terminal, and the pty is
closed when the session ends.
2026-10-13 00:58:14 +01:00
Dave Page 5a56a3f3dc Allow editing data through simple auto-updatable views. #2363 (#10322)
Views with a single base table, all of its primary key columns exposed,
no INSTEAD OF triggers and no rules other than _RETURN can be edited in
View/Edit Data. Updates and deletes are checked row by row, and adding
rows is disabled because inserts through views are not supported.
2026-10-13 00:56:35 +01:00
Dave Page bee294a373 Normalise the dashboard TPS graph by the measured time between samples. #10273 (#10324) 2026-10-13 00:30:24 +01:00
Dave Page 224c486c58 Exclude non-editable alias and expression columns from Query Tool UPDATE saves. #10103 (#10329) 2026-10-12 23:51:08 +01:00
Dave Page 2a952d678d Fix the UI language not persisting beyond the request that set it in server mode. #10347 (#10351)
The chosen language is now kept in the session and cookie, and any
value not in config.LANGUAGES is ignored.
2026-10-12 23:46:37 +01:00
Dave Page 3a6f86a711 Fix OpenAI streaming tool calls being split into two entries. #10348 (#10352)
Accumulate Responses API function calls by item, treating output_index
and the item id as names for the same call, and use
response.output_item.done for the final name, call_id and arguments.
2026-10-12 23:29:25 +01:00
Dave Page e85db0fb8b Allow ADMIN OPTION holders to manage Group Role membership. #9450 (#10315)
Users holding ADMIN OPTION on a role, directly or through an inherited
membership as PostgreSQL itself allows, can now change its members,
whilst all other property changes still require CREATEROLE.
2026-10-12 23:23:48 +01:00
Dave Page 0cbddf21ba Fix the hardcoded table OID in the column properties query. #10534 (#10535)
The inherited-columns CTE compared pg_inherits.inhrelid with a fixed
OID instead of the table being queried, so inherited columns were never
reported as such on PostgreSQL 14 and later.
2026-10-12 23:15:52 +01:00
Dave Page e2e588c324 Skip virtualisation for small DataGridView grids to avoid a re-measure on tab show. #10143 (#10331) 2026-10-12 22:58:30 +01:00
Dave Page 6302a70316 Drop the scaffolding default partition after Schema Diff rebuilds a partitioned table. #10301 (#10316)
The temporary default partition is dropped when empty and is not created
at all for hash-partitioned tables, which cannot have one.
2026-10-12 22:17:59 +01:00
Dave Page c41b606795 Replace free-text server-mode password exec commands with an administrator allowlist. (#10506)
In server mode, users choose a password exec command by name from
SERVER_PASSEXEC_COMMANDS rather than entering a command line. Existing
commands are converted on upgrade once the allowlist is configured; if
it is empty the conversion is postponed and a warning is logged.
2026-10-12 21:56:11 +01:00
Dave Page d924c15e79 Pass the target database to utilities as a connection string. #10463 (#10464)
The database is passed as a dbname= connection string, quoted when
needed, so service files and unusual names work, and backup and
import/export require a database for object-level jobs.
2026-10-12 21:47:33 +01:00
Dave Page 1530b3551d Don't refuse symlinks or force 0o600 permissions when saving files in desktop mode. #10482 (#10529)
O_NOFOLLOW and the 0o600 mode are only applied in server mode, and the
symlink refusal message is only reported when O_NOFOLLOW was set.
2026-10-12 21:34:38 +01:00
Dave Page 32f9b4a8f6 Fix Schema Diff scripts that convert a column to or from SERIAL. #10292 (#10318)
Converting a plain column to SERIAL now creates the sequence with the
source sequence's own type, moves it past existing values and restores
the nextval() default; converting away from SERIAL drops the default
before the owned sequence.
2026-10-12 20:50:45 +01:00
Dave Page 6b8394fe31 Write cloud wizard credentials through to the session store for AWS, Azure and Google. #10527 (#10528)
The providers keep credentials and authentication state in nested dicts
in the Flask session, which ManagedSession does not detect as modified,
and FileBackedSessionManager may also defer the disk write. Add a
write_session_through() helper that sets both session.modified and
session.force_write, and call it wherever a provider stores or clears
state that a later request depends on.
2026-10-12 20:08:22 +01:00
Dave Page a9f2cb5405 Fix the Object Explorer shortcut not moving keyboard focus into the tree. (#10294)
The shortcut called focus() on the rc-dock tab pane, a plain div that
cannot take focus, so focus stayed where it was. Focus the react-aspen
tree inside the panel instead, which is already focusable, and draw its
focus indicator with the theme's activeBorder colour on :focus-visible.
2026-10-12 20:05:50 +01:00
Dave Page 7c974c005c Restore remembering the last visited directory in Desktop file dialogs. #10226 (#10327)
Electron 43 deliberately stopped remembering the last folder used by the
native open and save dialogs, so they always opened in Downloads. Track the
last visited directory in the runtime's config store and pass it as
defaultPath whenever the caller has not specified a location.
2026-10-12 17:17:52 +01:00
Dave Page b3a941c469 Add JSON and XML export, output encoding, BOM and copy-with-headers options to the Query Tool results. #3205, #4128, #4129, #6695 (#10062)
Results can now be saved as JSON or XML as well as CSV, with preferences
for the output file encoding, prepending a byte order mark to CSV/TXT
files, and the default state of the grid's copy-with-headers toggle.
2026-10-12 17:17:43 +01:00
Dave Page 4682025f0c Fix BEGIN, COMMIT and ROLLBACK silently failing in the Query Tool under server cursor mode. #8991 (#10321)
execute_void() reused the cached server-side cursor, whose execute() cannot
run a transaction-control statement, and the resulting exception was
swallowed, so the statement never reached the database. Run such statements
through a throwaway plain cursor instead, and guard poll()'s error path
against a cursor with no query.
2026-10-12 17:16:32 +01:00
Dave Page e5b6728fa9 Improve keyboard shortcuts for tab switching, line duplication in the SQL editor, and Enter/Escape in dialogs. #3834, #5691, #7167, #7232 (#10067)
Tab switching now works when focus is inside a tool and defaults to
Ctrl+Alt+PageUp/PageDown, Ctrl/Cmd+Shift+D duplicates the current line or
selection in the SQL editor, and dialogs handle Enter and Escape
consistently.
2026-10-12 17:16:28 +01:00
Dave Page fad01ed776 Add the PostgreSQL 18 options to the Backup, Backup Server and Restore dialogs. #9064, #9066 (#10060)
Add --statistics-only, --no-policies, --no-data, --no-schema,
--no-statistics, --statistics and --sequence-data where the utilities
support them, gated on server version 18, and suppress combinations that
pg_dump, pg_dumpall and pg_restore would reject. The pg_dumpall --format
and pg_restore --exclude-database/--globals-only options were reverted
before PostgreSQL 18.0 and so are not included.
2026-10-12 17:16:24 +01:00
Dave Page d5bd079593 Fix index and constraint columns not being shown when their names require quoting. #6481 (#10039)
The expression check compared pg_get_indexdef() output, which is quoted,
with the raw attname, so such columns were treated as expressions and
dropped. Compare against quote_ident(attname) instead, and replace the
.strip('"') calls with an unquote_ident() helper that also unescapes
doubled inner quotes.
2026-10-12 17:16:20 +01:00
Dave Page 5fc540af73 Fix the collation and ctype query error for databases with a non-default LC_COLLATE. #9798 (#10040)
get_ctypes.sql wrapped a multi-row UNION in a scalar subquery, which failed
when a libc database's collation and ctype differed. Rewrite it as a flat
UNION, add a 15_plus variant, and read datlocale for ICU and builtin
providers on PostgreSQL 17+.
2026-10-12 17:16:17 +01:00
Dave Page 1058f34c52 Return False instead of raising when the external config database is unreachable. (#10052)
check_external_config_db() let connection failures and malformed URIs
propagate as exceptions. Create the engine inside the try block, return
False on any failure, and dispose of the engine in a finally block.
2026-10-12 17:16:13 +01:00
Dave Page 3d3eb8feb9 Disable the SSH agent when an SSH tunnel identity file or password is provided. #9814 (#10044)
SSHTunnelForwarder defaults allow_agent to True, so pgAdmin probed the
agent even when it had a credential of its own. Disable the agent in that
case, keeping it enabled when neither is configured.
2026-10-12 17:16:10 +01:00
Dave Page 125cb3007f Fix the OAuth2 userinfo request crashing when the endpoint is configured as None. #10349 (#10353)
The shipped OAUTH2_CONFIG template sets OAUTH2_USERINFO_ENDPOINT to None,
but get_user_profile() only checked whether the key was present, so it
called client.get(None). Check whether the value is set instead, apply the
same check to OAUTH2_LOGOUT_URL and OAUTH2_ADDITIONAL_CLAIMS, and clear a
stored logout URL when the provider has none or the callback fails.
2026-10-12 17:16:06 +01:00
Dave Page fa9762f345 Fix the MAINTAIN privilege being silently dropped for views and materialised views on PostgreSQL 17+. #10350 (#10354)
The PG17+ allowed_privs.json files for views and materialised views were
placed directly in the 17_plus bucket rather than under its sql/
subdirectory, so the versioned template loader fell back to the default
list, which lacks MAINTAIN. Move them under sql/ and add tests covering
the template path and granting MAINTAIN through RE-SQL.
2026-10-12 17:16:02 +01:00
Dave Page d24b946909 Remove the SonarQube scan workflow. 2026-10-12 17:11:46 +01:00
Yousif Wael bdb652b772 Remove inherited columns from the Columns tab when their parent table is removed after reopening a table. #10470 (#10504)
get_formatted_columns() copied the parent table name onto inherited columns
loaded from a saved table's properties, but not the parent's OID, so the
table dialog could not match those columns when the parent was removed from
'Inherited from'. Keep the first parent's OID, matching what the dialog does
when parents are added interactively.
2026-10-12 17:06:12 +01:00
dev-hari-prasad aaa0c8f14d Fix duplicated content when opening large non-UTF-8 files in the Query Tool. #10462 (#10508)
Files are streamed to the Query Tool in 4 MB chunks. If a byte that is not
valid UTF-8 appeared after the first chunk, the resulting UnicodeDecodeError
caused the file to be re-read from the start as latin-1, after earlier chunks
had already been sent, so they were delivered twice.

Check that the whole file decodes with the detected encoding before streaming
anything, falling back to latin-1 up front if it does not. Also replace the
deprecated codecs.open() with open(..., newline='') to preserve line endings.
2026-10-12 17:04:27 +01:00
Sanskar Kharya 641173d0fd Show default privileges in the Database properties panel. #10401 (#10502) 2026-10-12 17:03:10 +01:00
Dave Page ebe4617c00 Add a macOS 26 layered app icon with dark, clear and tinted variants. (#10530)
* Add a macOS 26 layered app icon with dark, clear and tinted variants.

The new pgAdmin4.icon Icon Composer document splits Slonik into body and
line layers over the existing light gradient, using the system dark fill
in dark mode. It is compiled ahead of time into Assets.car, so the build
does not need Xcode 26, and pgAdmin4.icns remains the icon on older
releases.

Closes #10473

* Match the macOS 26 icon's Slonik to the old icon's size and drop its glow.

Remove the thick white silhouette from the body layer, soften the group
shadow, and scale and position Slonik to match pgAdmin4.icns.

* Thin the macOS 26 icon's white lines to match pgAdmin4.icns.
2026-10-12 16:43:22 +01:00
Dave Page a4bdcff7dc Add pgEdge Starfleet to the cloud deployment wizard. (#10499)
* Add a pgEdge Starfleet API client for cloud deployment.

* Add a pgacloud provider for pgEdge Starfleet.

* Add the pgEdge Starfleet cloud deployment blueprint.

* Return the generated pgEdge Starfleet password once on deployment and allow saving it.

* Add pgEdge Starfleet to the Deploy Cloud Instance wizard.

* Test the Starfleet password is fetched once and validate the save request body.

* Show the generated pgEdge Starfleet password once after deployment.

* Document pgEdge Starfleet cloud deployment.

* Show the Starfleet password dialog at most once per job and handle copy failures.

* Wait for the Starfleet instance schema before rendering its form.

The schema is built after an asynchronous client IP lookup, but SchemaView
takes its initial data from the first schema it receives, so mounting it
early left the deployment type unset and hid the managed-only fields.

* Keep the Starfleet token for each deployment job until it completes.

The wizard clears its cloud session as soon as the deployment starts, so
the token was gone by the time the job finished and the generated
password could never be fetched. Copy the token for the job when it is
started, read the password with that copy, and discard it on completion.

* Show the reason a cloud deployment failed.

pgacloud prints the provider's error as JSON before it exits, but the
failure path ignored it, so the user saw only a generic process failure
whilst the server quietly vanished from the tree. Pass the message
through update_server and show it once per job as a plain-text error.

* Keep the cloud deployment failure message until it is dismissed.

* Explain an empty Starfleet BYOC cluster list.

* Wait for Starfleet connection details before finishing a deployment.

* Handle malformed Starfleet responses without a server error.

* Clear the Starfleet PostgreSQL version when the deployment type changes.

* Show the Starfleet size label in the deployment summary.

* Stop logging the values of a background process's environment.

* Use the official pgEdge logo for the Starfleet provider.

* Only mount the selected provider's credentials form in the cloud wizard.

* Require HTTPS and handle malformed pgEdge Starfleet responses.

Addresses CodeRabbit review on #10499: the API client refuses a non-HTTPS
URL before sending credentials, a 2xx body that is not JSON or lacks an
access token raises StarfleetError, and the provider and credential check
report an unexpected response shape instead of failing with a traceback.

* Reject an empty or non-string pgEdge Starfleet access token.

* Note that the pgEdge Starfleet API URL must use HTTPS.

* Add screenshots to the pgEdge Starfleet deployment documentation.

* Update the cloud provider screenshot to include pgEdge Starfleet.

* Remove pgEdge Starfleet BYOC support from the cloud deployment wizard.

Only Managed databases are now offered, so the Deployment type selector,
the cluster list, the BYOC PostgreSQL version list and the BYOC capability
probe are gone, along with the --kind and --cluster-id options of
pgacloud's starfleet create-instance command.

* Regenerate the Starfleet instance and review screenshots without the deployment type.

* Retry transient pgEdge Starfleet polling errors and write job tokens through.

A network error, rate limit or server error whilst waiting for a new
database no longer ends the deployment, leaving the database running in
Starfleet but removed from pgAdmin; polling now retries until its
deadline, failing at once only on other client errors.

The job's copy of the access token is now written to the session store
immediately, so a status poll handled by another worker can still fetch
the generated password.

* Write the pgEdge Starfleet wizard token through, and warn that a failed deployment may leave the database behind.
2026-10-12 14:28:49 +01:00
dependabot[bot] d082e8db27 Javascript dependency: Bump postcss-selector-parser in /web (#10523)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.5 to 7.1.6.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/7.1.5...7.1.6)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-12 09:17:48 +01:00
dependabot[bot] a3ec78d30c Javascript dependency: Bump source-map-js from 1.2.1 to 1.2.2 in /web (#10522)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-12 09:17:22 +01:00
dependabot[bot] 2b7a331896 Javascript dependency: Bump handlebars from 4.7.9 to 4.7.10 in /web (#10525)
Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.9 to 4.7.10.
- [Release notes](https://github.com/handlebars-lang/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.10/release-notes.md)
- [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.9...v4.7.10)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-version: 4.7.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-12 09:16:57 +01:00
Dave Page 79d138867a Require Flask-Security-Too 5.8.3 or later for the open redirect fix. (#10524)
5.8.3 fixes an open redirect in Flask-Security's default configuration
(GHSA-ccgp-pv8r-95wm). The fix is also in 5.9.1, but 5.9 needs Python
3.11 or later and removes get_post_login_redirect(), which pgAdmin
imports, so raise the lower bound within the 5.8 series instead.
2026-10-09 21:43:55 -04:00
dependabot[bot] 9712467844 Javascript dependency: Bump @mui/x-date-pickers in /web (#10519)
Bumps [@mui/x-date-pickers](https://github.com/mui/mui-x/tree/HEAD/packages/x-date-pickers) from 8.29.3 to 9.15.0.
- [Release notes](https://github.com/mui/mui-x/releases)
- [Changelog](https://github.com/mui/mui-x/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/mui-x/commits/v9.15.0/packages/x-date-pickers)

---
updated-dependencies:
- dependency-name: "@mui/x-date-pickers"
  dependency-version: 9.15.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 19:23:16 -04:00
dependabot[bot] 347312d8f7 Python dependency: Bump the regression-python-minor-and-patch group (#10513)
Bumps the regression-python-minor-and-patch group in /web/regression with 3 updates: [azure-identity](https://github.com/Azure/azure-sdk-for-python), [google-auth-oauthlib](https://github.com/googleapis/google-cloud-python) and [selenium](https://github.com/SeleniumHQ/Selenium).


Updates `azure-identity` from 1.25.3 to 1.26.0
- [Release notes](https://github.com/Azure/azure-sdk-for-python/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-identity_1.25.3...azure-identity_1.26.0)

Updates `google-auth-oauthlib` from 1.4.1 to 1.5.0
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/packages/gcp-sphinx-docfx-yaml/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-oauthlib-v1.4.1...google-auth-oauthlib-v1.5.0)

Updates `selenium` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/SeleniumHQ/Selenium/releases)
- [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.49.0...selenium-4.50.0)

---
updated-dependencies:
- dependency-name: azure-identity
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: regression-python-minor-and-patch
- dependency-name: google-auth-oauthlib
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: regression-python-minor-and-patch
- dependency-name: selenium
  dependency-version: 4.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: regression-python-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 19:16:15 -04:00
dependabot[bot] 5065de5ac3 Javascript dependency: Bump the runtime-javascript-minor-and-patch group (#10512)
Bumps the runtime-javascript-minor-and-patch group in /runtime with 3 updates: [electron](https://github.com/electron/electron), [eslint](https://github.com/eslint/eslint) and [globals](https://github.com/sindresorhus/globals).


Updates `electron` from 44.4.5 to 44.5.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.4.5...v44.5.1)

Updates `eslint` from 10.11.0 to 10.12.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.11.0...v10.12.0)

Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: runtime-javascript-minor-and-patch
- dependency-name: eslint
  dependency-version: 10.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: runtime-javascript-minor-and-patch
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: runtime-javascript-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 19:15:36 -04:00
Dave Page a5aa42843c Fix invalid SQL from Maintenance dialog REINDEX ... CONCURRENTLY. #10251 (#10314) 2026-10-09 18:51:04 -04:00
Dave Page ecb6446c31 Remove unused JavaScript and Python dependencies, and correct the dependency groups. (#10500) 2026-10-02 17:41:15 +01:00
dependabot[bot] ca21e94436 Javascript dependency: Bump webpack-cli from 6.0.1 to 7.2.3 in /web (#10495)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 6.0.1 to 7.2.3.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@6.0.1...webpack-cli@7.2.3)

---
updated-dependencies:
- dependency-name: webpack-cli
  dependency-version: 7.2.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:20:48 +01:00
dependabot[bot] a3ffb577ae Javascript dependency: Bump react-dropzone from 15.0.0 to 20.1.2 in /web (#10494)
Bumps [react-dropzone](https://github.com/react-dropzone/react-dropzone) from 15.0.0 to 20.1.2.
- [Release notes](https://github.com/react-dropzone/react-dropzone/releases)
- [Commits](https://github.com/react-dropzone/react-dropzone/compare/v15.0.0...v20.1.2)

---
updated-dependencies:
- dependency-name: react-dropzone
  dependency-version: 20.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:20:08 +01:00
dependabot[bot] c711abe98e Javascript dependency: Bump the web-javascript-minor-and-patch group (#10493)
Bumps the web-javascript-minor-and-patch group in /web with 3 updates: [ip-address](https://github.com/beaugunderson/ip-address), [postcss](https://github.com/postcss/postcss) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `ip-address` from 10.5.0 to 10.7.2
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.7.2)

Updates `postcss` from 8.5.26 to 8.5.28
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.26...8.5.28)

Updates `typescript-eslint` from 8.70.1 to 8.71.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-javascript-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-javascript-minor-and-patch
- dependency-name: typescript-eslint
  dependency-version: 8.71.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web-javascript-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:19:17 +01:00
dependabot[bot] b8dcd80ae1 Python dependency: Update pycodestyle requirement in /web/regression (#10492)
Updates the requirements on [pycodestyle](https://github.com/PyCQA/pycodestyle) to permit the latest version.
- [Release notes](https://github.com/PyCQA/pycodestyle/releases)
- [Changelog](https://github.com/PyCQA/pycodestyle/blob/main/CHANGES.txt)
- [Commits](https://github.com/PyCQA/pycodestyle/compare/2.14.0...2.15.0)

---
updated-dependencies:
- dependency-name: pycodestyle
  dependency-version: 2.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:19:12 +01:00
dependabot[bot] 0360a223a4 Javascript dependency: Bump electron (#10489)
Bumps the runtime-javascript-minor-and-patch group in /runtime with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 44.4.3 to 44.4.5
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.4.3...v44.4.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: runtime-javascript-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:19:06 +01:00
Dave Page 53ccc753e3 Align the cloud provider icons and labels in the deployment wizard. (#10498) 2026-10-02 16:03:13 +01:00
Murtuza ZabuawalaandDave Page 9f9bb553c4 Add a Statistics node for managing extended statistics. #2018 (#10310)
Adds browser support for PostgreSQL 14+ extended statistics objects
(CREATE STATISTICS), with the create/edit dialog, reverse engineered SQL,
the computed values ANALYZE collected where the role may see them, and
schema diff support. Completes Murtuza Zabuawala's #9748.

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-02 11:23:44 +01:00
Bea SteersandDave Page a807c99daf Allow an OAuth2 claim to restrict which shared servers a user can see. #9704 (#10234)
Adds OAUTH2_SERVER_GROUP_CLAIM and OAUTH2_SERVER_GROUP_CLAIM_MAPPING. When
the claim is configured for a provider, shared servers owned by other users
are only visible and accessible if their server group's name appears in the
(optionally mapped) claim values; if the claim is missing, none are. The
user's own servers, and users of other authentication methods, are
unaffected. The claim is resolved at login and only stored once the login
has been authorised.

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-02 09:26:27 +01:00
Rohit BhatiandDave Page 86f164d60c Fixed View/Edit Data not handling generated columns properly. #9672 (#9761)
Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-02 09:06:51 +01:00
dependabot[bot] dee788c581 Javascript dependency: Bump devalue from 5.9.2 to 5.9.4 in /web (#10488) 2026-10-02 09:00:46 +01:00
dev-hari-prasadandDave Page 7d3a170b9e Fix Kerberos login failures returning a 500 JSON response, and first-time auto-created Kerberos logins being rejected. #10419 (#10455)
When SPNEGO negotiation failed, the exception object was passed to flash(), so the session could not be serialised and the login page was replaced by a 500 JSON response. A generic message is now flashed; the detail is already logged.

Also fix a redirect to a non-existent 'authenticate.kerberos_login' endpoint (it is 'kerberos.login'), which had caused every Kerberos mock test to skip unconditionally, and return the username from Kerberos auto-creation so a user's first login is not rejected with "Incorrect username or password".

Co-authored-by: Dave Page <dpage@pgadmin.org>
2026-10-01 17:01:38 +01:00
Kundan Sable 467a200ca4 Fix 'Cannot have two HTML5 backends at the same time' in DataGridView. #10485 (#10487)
Share one explicit react-dnd manager between DataGridView and PgTreeView. react-arborist bundles react-dnd 14, which stores its manager under the same global key as react-dnd 16 but keeps a separate reference count, so unmounting one provider could drop the manager while its HTML5 backend was still attached to window.
2026-10-01 16:53:57 +01:00
dependabot[bot] cf2c096c4f Update @tanstack/react-table to 9.2.4 and migrate to the v9 API. (#10338)
Moves table construction to useTable() with a shared tableFeatures() registry (filtering, sorting, selection, expanding, sizing/resizing, visibility, row models, sort and filter functions), renames sortingFn to sortFn, adapts to v9 state access, prototype-bound row/cell methods and getRowCanExpand, feeds the DataGridView search state through table state instead of setOptions(), and lets Jest transform the ESM-only @tanstack packages.
2026-10-01 16:42:32 +01:00
dependabot[bot] e64b766ca3 Javascript dependency: Bump brace-expansion from 5.0.9 to 5.0.12 in /runtime (#10480)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.9 to 5.0.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 14:37:47 +01:00
dependabot[bot] cd82c8e4ec Javascript dependency: Bump the web-javascript-minor-and-patch group across 1 directory with 30 updates (#10486)
Also points the react-checkbox-tree webpack alias at lib/index.js, the 2.1 ESM build, as lib/index.esm.js is a stale 2.0.2 build that leaves the app blank.
2026-10-01 14:33:42 +01:00
Dave Page 25aec55eb2 Parse react-frame-component as CommonJS so 5.3 bundles correctly (#10467)
* Parse react-frame-component as CommonJS so 5.3 bundles correctly

react-frame-component 5.3 added "type": "module" to its package.json
whilst mapping the "require" condition of its exports to a UMD build.
Babel compiles our imports to require(), so webpack resolves the UMD
file and, honouring the package type, parses it as an ES module. With no
exports object in scope, the UMD wrapper falls through to its browser
global branch and the module exports nothing, so <Frame> is undefined.
Opening the Query Tool then throws React error #130 and unmounts the
whole app, which is why every feature test timed out with the 5.3.2
bump in #10460. Node shows the same packaging fault: require() of the
package returns an empty object.

Tell webpack to parse the package as javascript/auto so the UMD takes
its CommonJS branch. Jest is unaffected, as it loads the UMD as CommonJS.

* Bump react-frame-component to 5.3.2.

Take the version the webpack rule exists for in the same change, so CI
exercises the rule against it.
2026-10-01 11:41:47 +01:00
Matt Van Horn c83e0750c4 Explain Docker Compose quoting for PGADMIN_CONFIG_* values in the container docs. #10364 (#10465)
YAML consumes the quotes around a Compose scalar, so a value such as
PGADMIN_CONFIG_MAIL_SERVER: "mail.example.com" reaches config_distro.py
as a bare name and fails with NameError at startup. Document the nested
quoting needed in map syntax and env_file, how the list form differs,
escaping $ as $$, when config_distro.py is regenerated, and add a Compose
example.
2026-10-01 10:10:03 +01:00
Regina Obe 19a41432ac Add support for pg_timetable chains and tasks. #10148 (#10152)
Adds a pgTimeTable Chains node under Servers when pg_timetable is
installed in the maintenance database, with Tasks beneath each chain.
Chains and tasks can be created, edited and deleted, a chain can be
triggered with Run Now, and both have statistics tabs. Includes
regression tests, CI installation of pg_timetable, and documentation.
2026-09-30 17:28:49 +01:00
Guiorgy 7314f5069b Rewrite the container entrypoint using bash features, and pass values to embedded Python safely (#9510)
Reformat pkg/docker/entrypoint.sh to use bash conditionals, arithmetic and
parameter expansion instead of the workarounds needed for BusyBox ash.

The email validation settings and PGADMIN_DEFAULT_EMAIL are now read from
the environment inside Python rather than interpolated into the Python
source, so an address containing an apostrophe works, and a Python error
during validation stops the container instead of passing silently.
2026-09-30 15:58:59 +01:00
耗子andClaude Opus 5.5 5f58828e49 Fix mistranslations in the Chinese (Simplified) translation (#10454)
Correct 162 entries in the zh_Hans_CN catalogue: strings that are wrong
rather than merely worded differently, plus terminology aligned with
the Chinese PostgreSQL documentation.

- Restore the order of positional %s placeholders in three strings.
  The JavaScript gettext fills %s strictly left to right, so the
  reordered translations garbled the update notice, the preference
  load error and the user password length error.
- Keep the half-width colons in ': error: ', which ProcessDetails.jsx
  matches with a regex to highlight error lines, and the SQL comment
  marker in '-- missing definition'.
- Fix strings whose meaning was wrong or reversed, e.g. the ERD drag
  and drop message, relhassubclass, RESTART IDENTITY, ENABLE REPLICA,
  COLLATABLE, CREATE TABLE ... LIKE, and the DBMS Scheduler
  "Schedule" object being called a task.
- Give distinct labels to adjacent fields that shared one, and remove
  leftovers such as "表(s)", "会话token" and an untranslated
  "date/time".
- Use the established Chinese terms where the translation picked the
  wrong sense, e.g. Dependents (previously given the word for
  dependencies), local variables and the package body.
- Follow the terminology of the Chinese PostgreSQL documentation:
  注释 (comment), 字符分类 (LC_CTYPE), 操作符 (operator), 聚簇
  (CLUSTER), 提供者 (provider), 流复制 (streaming replication),
  预备事务 (prepared transaction), 载荷 (NOTIFY payload) and 过滤
  (filter).
- Translate Tab as 标签页 throughout, and the month-day options
  1st-31st as 1 日-31 日.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:24:11 +01:00
Ian 9d63ea91e2 Preserve database errors from index metadata queries (#10476)
When an index metadata query failed, the Properties and SQL handlers
could replace the original database error with 'string indices must be
integers', or with an error about using a Response as index metadata.
get_column_details() read rset['rows'] before checking the query status,
and get_column_details(), get_include_details() and get_sql() returned
HTTP responses where their callers expect data.

Raise ExecuteError instead, as get_reverse_engineered_sql() already
does, so the original database error reaches the user. Adds regression
tests covering Properties, SQL, modified SQL and update.
2026-09-30 15:23:56 +01:00
dependabot[bot] a0723f9db8 Javascript dependency: Bump brace-expansion in /web (#10479)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.18 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 14:38:42 +01:00
dependabot[bot] 2a65e506a6 Javascript dependency: Bump moment from 2.30.1 to 2.31.0 in /web (#10478)
Bumps [moment](https://github.com/moment/moment) from 2.30.1 to 2.31.0.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0)

---
updated-dependencies:
- dependency-name: moment
  dependency-version: 2.31.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 14:38:23 +01:00
dependabot[bot] a100b86ff2 Javascript dependency: Bump ip-address from 10.5.0 to 10.7.1 in /web (#10477)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.5.0 to 10.7.1.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.7.1)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 14:38:06 +01:00
Sanskar Kharya bdd3282c49 Honour the pgpass file in the Change Server Password dialog (#10451)
The dialog hardcoded the pgpass flag to false, so it always demanded the
current password even when the connection authenticates via a passfile.
Pass the flag from check_pgpass through to the schema instead.

check_pgpass and change_password also disagreed about when a passfile is
in use: the former treated a password entered at connect but not saved
as a passfile connection, which would now disable the Current Password
field whilst the endpoint still required it. Both now use a shared
is_using_passfile() helper.

Fixes #10449
2026-09-30 13:29:14 +01:00
dev-hari-prasad f6a2c1037e Resolve the packaged Electron version from runtime/yarn.lock (#10413)
The Windows, Linux and macOS builds now read the Electron version from
runtime/yarn.lock using 'yarn info electron --json' inside runtime/,
rather than querying the npm registry or using the lower bound of the
range in runtime/package.json. This needs no network access or
node_modules, and packages exactly the version that yarn install
resolves. The build stops with an error if the lockfile is missing or
does not yield a valid version.
2026-09-30 13:27:56 +01:00
dependabot[bot] 3515541320 Javascript dependency: Bump undici from 7.29.0 to 7.30.0 in /runtime (#10471)
Bumps [undici](https://github.com/nodejs/undici) from 7.29.0 to 7.30.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.29.0...v7.30.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.30.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 10:35:09 +01:00
dependabot[bot] 369c12c1b2 Javascript dependency: Bump fast-uri from 3.1.6 to 3.1.8 in /web (#10469)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.6 to 3.1.8.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.6...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 09:24:45 +01:00
dependabot[bot] 88b599e98f Javascript dependency: Bump fast-uri from 3.1.6 to 3.1.8 in /runtime (#10468)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.6 to 3.1.8.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.6...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 09:24:40 +01:00
dependabot[bot] 4671d2f221 Javascript dependency: Bump electron-context-menu in /runtime (#10457)
Bumps [electron-context-menu](https://github.com/sindresorhus/electron-context-menu) from 4.1.2 to 5.1.0.
- [Release notes](https://github.com/sindresorhus/electron-context-menu/releases)
- [Commits](https://github.com/sindresorhus/electron-context-menu/compare/v4.1.2...v5.1.0)

---
updated-dependencies:
- dependency-name: electron-context-menu
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 12:50:32 +01:00
dependabot[bot] 8f487ac666 Javascript dependency: Bump the runtime-javascript-minor-and-patch group (#10456)
Bumps the runtime-javascript-minor-and-patch group in /runtime with 2 updates: [electron](https://github.com/electron/electron) and [eslint](https://github.com/eslint/eslint).


Updates `electron` from 44.4.2 to 44.4.3
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.4.2...v44.4.3)

Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.10.0...v10.11.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: runtime-javascript-minor-and-patch
- dependency-name: eslint
  dependency-version: 10.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: runtime-javascript-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 12:25:05 +01:00
dependabot[bot] 6e22b29770 Python dependency: Bump psycopg (#10459)
Bumps the regression-python-minor-and-patch group in /web/regression with 1 update: [psycopg](https://github.com/psycopg/psycopg).


Updates `psycopg` from 3.3.5 to 3.3.6
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.3.5...3.3.6)

---
updated-dependencies:
- dependency-name: psycopg
  dependency-version: 3.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: regression-python-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 12:24:53 +01:00
dependabot[bot] ad3ba69ca5 Python dependency: Bump psycopg in the python-minor-and-patch group (#10458)
Bumps the python-minor-and-patch group with 1 update: [psycopg](https://github.com/psycopg/psycopg).


Updates `psycopg` from 3.3.5 to 3.3.6
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.3.5...3.3.6)

---
updated-dependencies:
- dependency-name: psycopg
  dependency-version: 3.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 12:24:41 +01:00
Dave Page fd7b462f3d Run the scheduled snapshot only on pgadmin-org/pgadmin4 (#10452)
A fork with Actions enabled inherits the nightly schedule but none of the
signing secrets or self-hosted runners, so its snapshot fails at the macOS
certificate import and leaves the Windows job queued for a day. The
location job now skips a scheduled run anywhere else, and every build job
waits on it so that the whole run skips with it. A manual dispatch still
runs anywhere.
2026-09-24 08:04:59 +01:00
Dave Page efa8ac7948 Fix TestHeaderOnLiveLogoutRequest failing in desktop mode
The test asserted that no X-Remote-User header is sent on the request
after a logout, which only holds in server mode. In desktop mode the
before_request hook logs the desktop user straight back in, so the
header is reported again, and every run-python-tests-pg job (which runs
in desktop mode) has failed on it since #10410. Assert the mode-specific
behaviour instead.
2026-09-23 15:05:04 +01:00
Dave Page 0dc122cb2e Sign Windows builds with SHA-256 file digests
The one-step "signtool sign /fd sha256" fails on the build host with
0xc0000225 before the PIN is asked for, because crypt32 converts the
legacy CryptoAPI key handle to a CNG one for SHA-2 digests, and no CNG
provider is registered under the name of Certum's crypto3 CSP. Rebinding
to cryptoCertum3 KSP is not an option, as it cannot see the Common
profile's code-signing key.

signtool's split flow avoids the conversion, so pkg/win32/sign-files.bat
now does the signing in /dg, /ds, /di, timestamp and verify steps, with
one PIN prompt per call as before. Make.bat's :SIGN_FILES and Inno
Setup's SignTool both go through it, the latter via cmd /c.

Also default PGADMIN_SIGNTOOL_DIR to SDK 10.0.26100.0, the one installed
on the host, and match the PIN dialog on "Logowanie do profilu zwyk" so
that a Secure profile prompt is never answered with the Common PIN.
SIGNING.md gains the full setup procedure, the root cause and the split
signing flow.
2026-09-23 14:10:10 +01:00
Dave Page c8ae16d13f Document adding and retiring Linux build platforms
A new Debian, Ubuntu, Fedora or EL release is named in seven places across
the build matrices, the snapshot and release workflows, the acceptance tests
and the publishing wrapper, and missing the wrapper leaves the packages built
and uploaded but never indexed. pkg/linux/PLATFORMS.md lists them all, with
the steps for adding a platform, retiring one and the server-side follow-up.

The platform lists in the Debian and Red Hat READMEs had drifted (Ubuntu
25.04, Fedora 40 and 41, RHEL 8), so they now point at the build matrices
instead.
2026-09-23 10:53:09 +01:00
Dave Page dd0d8ac533 Revert Windows code signing to SHA-1 file digests
#10355 moved signtool to /fd sha256 on the assumption that the key
provider repair described in pkg/win32/SIGNING.md made SHA-256 possible.
It does not: that repair binds the certificate to the Certum card's
legacy CryptoAPI provider, crypto3 CSP, which fails a SHA-256 file digest
in SignerSign() with 0xc0000225 before the PIN is requested, whilst
/fd sha1 signs and verifies. SHA-1 is what every previous release has
shipped with, so go back to it for both the component and the installer
signatures, and document why.

The timestamp digest stays at SHA-256, which works. Rebinding the
certificate to Certum's CNG provider is the likely route to SHA-256 file
digests, but is untested and is left for later.
2026-09-23 10:26:37 +01:00
Dave Page d0315c58ec Fix the Windows environment check, which cmd could not parse
The messages that report a missing prerequisite were changed from !VAR!
to %VAR% in commit afa0c773a, since the file does not enable delayed
expansion and they printed the variable name rather than the path. That
made them correct and unparseable at the same time: cmd expands %VAR%
when it parses a parenthesised block rather than when it runs it, and
PGADMIN_INNOTOOL_DIR defaults to "C:\Program Files (x86)\Inno Setup 6",
whose closing parenthesis then ended the IF block early. Every Windows
build has failed at "Checking the environment..." since, with "\Inno was
unexpected at this time", whether or not Inno Setup was installed, which
took the nightly snapshot with it.

The four checks move into a :REQUIRE_DIR subroutine that takes the
directory and the hint, and echoes both outside any block, where a
parenthesis is just a character. Enabling delayed expansion would also
have worked, but only by making ! a metacharacter for the whole file.
The other uses of these two paths are all inside double quotes, which
the parser does respect, so they were never affected.
2026-09-23 09:01:54 +01:00
Dave Page 43d8b3fabf Rename the server 'Change Password' option to 'Change Server Password' (#10035)
The 'Change Password...' option on a server node was ambiguous, because it
changes the password of the PostgreSQL role that pgAdmin connects with, yet
read as though it might change the pgAdmin login password instead. The menu
option and its dialog are now called 'Change Server Password', with the
connected role name in the dialog title, and the equivalent option in the
user menu becomes 'Change pgAdmin Password', so that the two can no longer
be confused with one another.

The dialog's own 'User' field was always blank, since the userName prop that
showChangeServerPassword passes was never accepted by ChangePasswordContent;
it is now wired through, which also makes the existing documentation of that
field accurate. The documentation for both dialogs has been updated to match
the new names, along with a couple of button names that had drifted.

Fixes #9230.
2026-09-22 17:15:12 +01:00
dev-hari-prasad e78c7fe020 Fix Query Tool tabs auto-running their query after a session restore (#10055)
Typing in the editor of a View/Edit Data tab promotes it to a Query Tool, but
that promotion only ever updated the component's own state: the tab's saved
metadata kept the original tool URL, with is_query_tool=false in its query
string. On the next start-up the tab was therefore restored as a View/Edit Data
tab, and View/Edit Data tabs execute their query as soon as the connection is
made, so whatever SQL the user had left in the editor ran unprompted. A
restored DELETE or UPDATE running by itself is not a pleasant way to start the
day.

toggleQueryTool now rewrites is_query_tool in the panel's stored toolUrl and
saves the layout, so a promoted tab comes back as a Query Tool and no longer
takes the auto-execute path. The URL is kept relative, because in Desktop mode
the runtime picks a fresh port on every launch and an absolute URL would point
the restored tab at a dead origin.

is_query_tool and restore are also normalised to real booleans up front rather
than compared against the string 'true' in each place they are read, and a
failed restore no longer leaves the editor disabled with nothing logged.

Fixes #10031
2026-09-22 17:11:02 +01:00
Dave Page 2ba68de777 Add Caddy reverse proxy configuration examples (#10344)
Adds Caddy examples alongside the existing NGINX ones for hosting pgAdmin with
Gunicorn or uWSGI, both at the root of a server and under a sub-directory,
mirroring the structure of the NGINX sections. Caddy does not speak the native
uwsgi protocol, so its uWSGI examples run uWSGI in HTTP mode instead.

Documents the two things that must line up for a sub-directory deployment
behind any reverse proxy: the proxy forwarding the request path unmodified
with an X-Script-Name header, and a SCRIPT_NAME environment variable on the
pgAdmin process itself, which is read at startup to set APPLICATION_ROOT and
SESSION_COOKIE_PATH. The missing environment variable is what caused the
"login succeeds then bounces back to the login page" report in #10334.

Also notes that pgAdmin trusts X-Script-Name and X-Scheme unconditionally,
unlike the X-Forwarded-* headers, so every example now either overwrites or
clears both rather than passing on whatever the client sent; warns that the
systemd unit shipped with Caddy sets PrivateTmp=true, which hides a socket
created in the system /tmp; and refreshes the stale python3.7 paths.
2026-09-22 17:03:47 +01:00
Dave Page afa0c773ad Sign the bundled PostgreSQL binaries in the Windows build (#10355)
The PostgreSQL and Kerberos utilities and libraries we ship in the
Windows package are not signed, whilst the installer, the uninstaller
and pgAdmin4.exe are. Nothing verifies an Authenticode signature when
these run, so this is not a security fix, but it does make life easier
for organisations running application allow-listing under WDAC or
AppLocker, where publisher rules are simpler and more durable than path
or hash based ones, and it cuts down on antivirus false positives.

The signtool invocation moves into a :SIGN_FILES subroutine, and a new
:SIGN_COMPONENTS step signs everything we build ourselves once
:CREATE_RUNTIME_ENV has staged it. Names are matched through DIR /B so
the optional libraries are skipped when absent, and the whole thing is a
no-op when PGADMIN_WINDOWS_CSC is unset, so unsigned developer builds
are unaffected. The Electron, Python and VC++ runtime components are
deliberately left alone, being third party binaries whose own signatures
ours would replace. Everything goes through a single signtool call,
since the hardware token prompts for a PIN on the first signature of a
session.

The file digest moves from SHA-1 to SHA-256 for both the runtime and the
installer. SHA-1 was never a deliberate choice: it dates from the move
to the Certum token, at a point where the certificate in the machine
store had no key provider link, which limits signing to SHA-1. The
buildfarm now repairs that link with certutil when it provisions the
host.

The four optional PostgreSQL libraries were copied without checking the
result; they now abort the build like the others do.

PGADMIN_POSTGRES_DIR and PGADMIN_KRB5_DIR defaulted to an EDB PostgreSQL
installation and an MIT Kerberos for Windows installation respectively,
whereas both sets of binaries have come from winpgbuild for some time
and the buildfarm points at C:\build64\postgresql and C:\build64\krb5.
The four "does not exist" messages that report a missing prerequisite
used !VAR!, but the SETLOCAL at the top of the file does not enable
delayed expansion, so they printed the variable name rather than the
path that was looked for.

Finally, the acceptance test now verifies the signatures on everything
:SIGN_COMPONENTS signs, walking the same list of names where the files
land: none of them exist outside the installer until the installer has
run, so that is the only place a published build can be checked. The
expected publisher is taken from the installer itself rather than hard
coded, so a component signed by somebody else is caught without the
workflow needing an edit when the certificate is renewed.
2026-09-22 17:02:38 +01:00
dev-hari-prasad 351376af7f Enforce --immutable on every yarn install, in CI and the release builds (#10389)
In the four GitHub Actions workflows the flag is documentation only, since
Yarn's enableImmutableInstalls already defaults to true whenever CI is set in
the environment, but making it explicit means the intent is visible in the
workflow rather than implied by the runner.

The six installs that run outside CI are where this actually changes
behaviour: the Makefile, the Dockerfile, and the pip, Linux, macOS and Windows
packaging builds all run with yarn.lock beside them, and until now any of them
would quietly rewrite the lockfile rather than fail. A release build that
rewrites the lockfile is shipping something other than what we reviewed, so
failing loudly there is the point of the exercise.
2026-09-22 16:50:39 +01:00
Domenico Sgarbossa 91e7747226 Updated message catalogs for v9.18 (#10448) 2026-09-22 16:43:37 +01:00
Dave Page 84a76baa8e Pin against pgbuild's immutable releases, now that they exist
The lock was written against the rolling tags because the per-version
releases did not exist when it was generated, and the refresh tool assumed
PostgreSQL was tagged by major, which it is not: the release that never
changes is postgresql-18.6, whilst only the rolling one says 18. The tool
now resolves a major to the version pgbuild's manifest names, and a tag that
does not exist is an error rather than something to fall back from, since a
lock recording an archive nobody can fetch is worse than no lock at all.
2026-09-22 16:17:05 +01:00
Dave Page c603393827 Run the Windows and macOS build checks when the dependency lock changes
A lock change decides which PostgreSQL, OpenSSL and Kerberos binaries end up
inside the installer, so it is exactly the kind of change those checks exist
for, and without this it was the one packaging input that could move without
either of them running.
2026-09-22 14:31:45 +01:00
Dave Page ce6c049dbc Pin and verify the pgbuild build dependencies
The install-pgbuild-deps action fetched PostgreSQL, OpenSSL, Kerberos, zstd
and lz4 from pgbuild release tags ending in -latest, whose assets are
replaced in place on every rebuild, and unpacked them with no checksum or
signature check anywhere in between. Since the resulting build is
Authenticode signed on Windows and Developer ID signed and notarised on
macOS, that made write access to pgbuild equivalent to write access to what
pgAdmin ships, and it meant a build was not reproducible either, the same
commit producing different binaries depending on the day it was built. This
is pgadmin-org/pgbuild issue 1.

pkg/pgbuild-deps.lock now records, for every dependency the action can
fetch on either macOS architecture and on Windows, the per-version release
tag to download from and the SHA-256 its archive must hash to. The action
fetches that tag, checks the digest before unpacking, and fails on a
mismatch or on a dependency the lock does not mention, since an unpinned
dependency reaching a signing build is the thing this is meant to prevent.
pgbuild publishes a .sha256 beside each archive, which is deliberately not
consulted: whoever rebuilds an archive rebuilds that too, so the lock is
the only thing here that pins anything.

Being out of date is a different matter from being wrong, so once an
archive is verified the action asks pgbuild what it has published since,
and warns rather than fails when the pinned build is no longer the newest.
That check is best effort and never fails a build whose downloads have
already been verified.

tools/refresh_pgbuild_lock.py rewrites the lock, taking the version to pin
from pgbuild's own manifest.json and working one package at a time so that
bumping OpenSSL does not restate every other digest. The diff it produces,
with the upstream version visible in the tag column, is the audit trail the
previous arrangement had none of.

The digests were taken from the archives pgbuild publishes today, which are
the same archives its per-version releases will carry, but those releases
do not exist yet, so the tags in the lock cannot be fetched until pgbuild's
own change lands.
2026-09-22 14:30:43 +01:00
Dave Page bccef24bf7 Drop from= from the authorized_keys templates, which do not carry it
The deployed files are generated rather than hand written, and that option is
not one we can set there, so the templates described a restriction production
does not have. What a key may do is still bounded by restrict and the forced
command, which is the part that matters; what is lost is the second answer to
where it may be used from.
2026-09-22 13:07:19 +01:00
Dave Page e543aaa6f9 Bring install-repo-readme.py up to the project's pep8 settings
It has been failing check-python-style since it landed: five lines over the
limit, and two breaks before a binary operator, which count here because
.pycodestyle sets an explicit ignore list and so loses pycodestyle's own
default exemption for W503.
2026-09-22 11:40:50 +01:00
Dave Page 720dd3714f Ship the docs tarball with its versioned directory, as releases always have
The web server unpacks this into the documentation root and then hands
docloader the directory name pgadmin4-<version>-docs, so a flat archive would
have scattered several hundred HTML files across that root and then failed on
a directory that was never created. Every published tarball carries that top
level; the one assembled here did not, and the first release to notice would
have been the next one.
2026-09-22 11:25:32 +01:00
Dave Page af5fd2acc3 Move the archive sync and release purge scripts into the publishing tooling
sync-ftp-to-s3.py and purge_pgadmin_releases.sh have been living in the
pgaweb checkout under tools/, which was never where they belonged: neither
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 move here, renamed to match the hyphenated names of the
scripts already in this directory, and the README gains them in the file
table, in the install line and in the prose that explains what lives where.

The sync-s3 verb now runs sync-ftp-to-s3.py out of PUBLISH_DIR instead of
TOOLS_DIR, which makes this a deployment-ordering change: the wrapper and the
copy of the script it names must both be installed on the download server
before the pgaweb side deletes its own copy, and the comment on the verb says
so. The three verbs that remain genuinely website operations, create-release,
load-docs and purge-cache, still reach into TOOLS_DIR and are untouched.

purge-pgadmin-releases.sh deliberately gets no verb. It is a by-hand
operation that deletes published content, it defaults to a dry run that
somebody is expected to read before re-running with --live, and the rebuild
scripts it pairs with are already documented as standalone; handing a
compromised runner the ability to delete releases would undo most of what the
wrapper is for.

Both scripts are otherwise unchanged apart from the filename in the purge
script's own header comment, and four lines in the Python rewrapped to 79
columns because pkg/ is covered by check-pep8 and the originals were not.
2026-09-22 11:19:39 +01:00
Dave Page 2343f60f04 Select the publishing runner by role rather than by its hostname
The runner has carried both labels all along, so this is the last of the
machine names out of the tree; roadie can stay on the runner until these have
run green, and then go.
2026-09-22 11:06:25 +01:00
Dave Page 5f102aa36c Do not claim the PIN helper was shipped as a compiled executable 2026-09-22 11:03:40 +01:00
Dave Page 1b392caa0f Say how the PIN helper is installed and started
The section named the script and left the reader to work out the rest: where
AutoHotkey comes from, where the script should live given the runner deletes
its own workspace, how it starts in the interactive session alongside the
runner, and how to tell whether it is running at all.
2026-09-22 10:46:59 +01:00
Dave Page 8bca7e0c57 Retry the notarisation wait rather than failing the build
xcrun notarytool wait polls Apple for as long as the notarisation takes, and
exits non-zero if any single poll fails, so a runner losing its route for a few
seconds failed a notarisation that was proceeding perfectly well: the 22nd
September snapshot died twenty six minutes into waiting for a DMG Apple had
already accepted, with "The Internet connection appears to be offline".

The wait is now retried ten times at a minute apart, without resubmitting,
since the submission is still queued under the same ID. Each failure asks for
the status directly, because the wait also exits non-zero when a submission
genuinely ends as Invalid or Rejected, and any terminal status ends the loop
and leaves the existing check to decide what it means.
2026-09-22 10:40:08 +01:00
Dave Page 475b864e7f Stop the publishing pipeline overwriting what it has already published
Every one of these is a way that a verb meant only to add something would
quietly change something already published, which is the failure the wrapper
exists to prevent, so they are worth having together.

Snapshot retention kept the newest five directories, and a directory is
something either key can create: five dated today with high suffixes filled
the keep set and every genuine snapshot was deleted. It now keeps every
directory whose date is among the newest five that are not in the future, so
retention is counted in days, and the intended consequence is that a day with
several builds keeps all of them.

release-fetch required only that v<VERSION> existed, and shutil.move onto an
existing file replaces it, so a release published months ago could be
overwritten and autoupdate-publish and sync-s3 would then carry the
replacement to the auto-updater and into an archive that has no undo. The
single-shot guarantee that release-create made for the first verb now covers
the whole sequence: it refuses before moving anything if .incoming exists or
if a content directory holds more than its maintainer marker, and refuses each
individual move whose destination is already there.

packages-fetch pulled with no --ignore-existing, so a staging package whose
filename matched a published one replaced it and the following rebuild signed
the new bytes. It now leaves the live copy alone and reports what it skipped,
since a collision during a real promotion means two builds have produced one
filename.

autoupdate-publish deleted the live archives before moving the new ones in,
which left a window with nothing there at all; the order is reversed and only
archives this release did not replace are removed, and once every move has
succeeded. --delay-updates leaves .~tmp~ behind in the live trees when a
transfer is interrupted, and both indexers descend into it, so the holding
directories are swept immediately before indexing rather than moved out of the
tree with --partial-dir, which would make the final rename a copy across
filesystems. DATESTAMP_RE and VERSION_RE gain re.ASCII, because \d matches any
Unicode decimal digit and int() parses those, so a fullwidth datestamp on the
filesystem read back as an ordinary date.

In the workflows, the repository variables naming the SSH target and the key
paths reached option positions, as the first positional argument to ssh and
inside the -e string rsync splits on whitespace. Only an administrator can set
those, so this is defence in depth: they are validated against a conservative
pattern in the step that already checks the keys are present, and the target
is passed after --. snapshot.yml passed the raw inputs.location to its publish
step rather than the validated output of the location job, which agreed only
by coincidence, and it now carries a flag for whether a location was named so
that an empty input still means the assembled directory name.
check-container-build.yml gains the top-level permissions: contents: read the
other workflows have, leaving the publish job's packages: write alone, and
promote-release.yml verifies the wheel's detached signature against the
published signing key before uploading it to PyPI, the mirror having been
trusted on TLS alone until now.

The README and the download server's key annotations are updated to match: the
guarantees they stated about single-shot publication and about retention were
overstated, and are now true.
2026-09-22 10:31:40 +01:00
Dave Page d04e39cc65 Describe the signing host without naming whose rack it is in 2026-09-22 10:24:53 +01:00
Dave Page b7789be4af Bring the Certum PIN handler into the repository
The script that answers the signing card's PIN prompt lived only in the
buildfarm repository, as an Ansible template with the PIN itself interpolated
into it. That repository is going away, and the template could not come with it
as it stood, so this is the same script with the secret taken out and the two
faults that cost a build this morning fixed.

The PIN now comes from a file in the build user's local application data, read
once at startup, so neither this repository nor the compiled executable carries
it; the helper says so and exits if the file is missing or empty, rather than
sitting in the tray looking healthy whilst answering nothing.

The build was lost to a race. The original waited for the dialog and then
activated it as two separate operations, and when the activate lost that race
and threw "Target window not found", AutoHotkey put up its default error dialog
on a machine with nobody to dismiss it, so one transient fault became an
indefinite hang. So the work now happens on a timer rather than in a loop that
can end, activation is retried for a couple of seconds and addressed by window
handle rather than by title, a window that vanishes mid-operation is a log line
and a return rather than an error, and an OnError handler makes it impossible
for anything to put a dialog on the screen at all.

Whilst there, the PIN is sent with SendText rather than Send, which would have
read {, +, ^, ! and # in it as modifiers, and it is only sent once WinActive
confirms the card's own dialog has the focus, because a blind send on a build
machine is a good way to type a PIN into a console whose output gets uploaded.
The title is matched on its leading unaccented substring, which keeps the file
to plain ASCII and survives proCertum wording changes.

SIGNING.md gains the operator's half of that: where the PIN file goes, the
icacls to lock it down, where the log is, and what each thing the log can say
means when a signed build hangs.
2026-09-22 10:24:02 +01:00
Dave Page 97a7e55b96 Close the review's remaining findings in the workflows
The promotion validated its inputs in the promote job, which the registries
scope skips, so under that scope the typed-twice confirmation did not cover
the step that moves the latest tag; validation moves to a job both paths
depend on. The container tag loops no longer glob against the workspace, and
check-container-build validates the location it interpolates into a push tag
in a job holding the Docker Hub credentials.

The acceptance test built its apt sources line with sh -c, which re-parsed the
expanded base URL as source: the one place in the pipeline where a
caller-supplied value reached a shell. The outer shell writes the file now,
and the URL is checked once, against a host allowlist, before any job
downloads and installs what that host serves as root.

The actions in jobs holding registry or trusted-publishing credentials are
pinned by commit rather than by a movable tag, as the SonarQube action already
was, and so is the helm installer piped into a shell beside them.

Also: the README understated the signing verbs, which select by extension
across the tree rather than by anything tied to a build.
2026-09-22 10:00:17 +01:00
Dave Page ff9819e222 Make snapshot retention safe, and stop packages-fetch importing staging's indices
snapshot-purge sorted directory names and kept the last five, so a name that
merely sorted late survived and everything real fell off the end: five
snapshot-create calls with a future datestamp, which the verb accepts up to
two days ahead, were enough to have the next purge delete every genuine
snapshot. Anything dated after today is now left out of the retention set
entirely, and the ordering is by date and then by suffix as a number, which
also fixes 2026-09-22-10 sorting before 2026-09-22-2 and so a day with more
than nine builds keeping the older ones.

packages-fetch pulled staging's Packages files and repodata into the live
tree, whilst the signed Release, Release.gpg and InRelease they are checked
against live a directory further up and were left as they were. Every live
apt repository therefore failed on a hash sum mismatch from the moment the
fetch landed until that codename's rebuild caught up, which with five
sequential rebuilds each taking the lock is minutes. The docstring already
claimed the indices were left behind; now they are.

The pull key is renamed to pga-pull to match the other two.
2026-09-22 09:58:02 +01:00
Dave Page 355d6d099f Name the publishing hosts by role rather than by hostname
The machine names were only ever in comments and two filenames, but the
repository is public and the buildfarm never was, so there is no reason to hand
a reader the mapping from internal name to public service. The two
authorized_keys templates become .staging and .download, and the prose refers
to the staging server, the download server and the runner.

The pull key the download server uses is renamed to match, so it needs moving
on that host: ~pgaupload/.ssh/id_ed25519_paxsor_pull -> id_ed25519_pull. The
runner's own key paths come from repository variables, so the defaults here
changing affects nothing.
2026-09-22 09:49:07 +01:00
Dave Page da0bc3ecd9 Say which platforms read pkg/python-version.txt
Only the Windows and macOS packaging does, those being the two that ship an
interpreter; the Linux packages use the distribution's Python.
2026-09-22 09:38:49 +01:00
Dave Page db2030e9c0 Describe where aptftp.conf lives rather than telling the reader to move it 2026-09-22 09:23:31 +01:00
Dave Page 34cb0be9a2 Make a scheduled snapshot actually publish
Both publishing steps were gated on inputs.publish, which exists so that a
dispatched test run builds without uploading. A schedule event carries no
inputs, so that expression was null rather than false and the nightly built
everything, assembled the tree and threw it away, reporting success. Publish
unconditionally on a schedule, and keep the input for dispatches.
2026-09-22 09:06:35 +01:00
Dave Page ca275cbc8d Let the registry half of a promotion be rehearsed on its own
The container re-tag and the chart push need no download server and touch
nothing that cannot be deleted, but they were reachable only through a real
promotion, which is the one thing we cannot practise. A "registries" scope
skips the promote job entirely and runs just those two, leaving the PyPI
upload out since a rehearsal has no published wheel to upload.
2026-09-21 21:49:13 +01:00
Dave Page c28362013f Give the container publish its own job, on the publishing environment
The Docker Hub credentials are environment secrets, and an environment
secret is not available to a job that does not name the environment.
secrets: inherit passes repository secrets, so the login failed with
'Username and password required' and would have done the same in a real
candidate run.

Splitting it also keeps the credentials away from the build check, which
runs on every pull request and has no business being able to push.
2026-09-21 16:26:02 +01:00
Dave Page b2d1362b8a Let the container build be dispatched with its publish inputs
They were only on workflow_call, so the push path could not be exercised
without running an entire release candidate to reach it.
2026-09-21 16:24:51 +01:00
Dave Page 24a1f24e48 Add a button for purging the website cache
The last thing in the release process still needing Jenkins. Promotion
purges the cache, but the website edits that follow a release happen
afterwards and are all cached, so there has to be a way to purge it on
its own.

No arguments: the verb behind it clears everything, and there is nothing
for a caller to choose.
2026-09-21 16:11:13 +01:00
Dave Page e1915c7dc0 Publish the container, chart and wheel from Actions
The last three things still running from Jenkins, and the reason the
buildfarm could not be switched off.

The container is built for both architectures and pushed under the
candidate's location, then re-tagged at promotion with buildx imagetools
rather than rebuilt, so what ships is the manifest the acceptance test
ran against. It goes to Docker Hub and to GHCR: Docker Hub stays
canonical because every tutorial and compose file in the world names
dpage/pgadmin4, whilst GHCR is organisation owned, needs no stored
credential and is not subject to Docker Hub's anonymous pull limits.

The Helm chart moves to GHCR outright. Its version comes from Chart.yaml
so there is no candidate copy to re-tag, its install base is a fraction
of the container's, and oci://docker.io/dpage was an odd home for a
chart.

The wheel is fetched from the published release rather than rebuilt, so
what reaches PyPI is the file the acceptance test installed, and it goes
up by trusted publishing, which exchanges the workflow's own identity for
a short-lived credential rather than holding an API token.

All three run in their own job on a hosted runner. They need none of the
publishing keys, so the machine that holds those never handles registry
credentials, and a registry outage cannot leave a half-finished
promotion on the download server.
2026-09-21 16:10:13 +01:00
Dave Page 89bb92dc44 Read the staging server's name from the server
It was the last SITE value the repository had to get right, and it was a
fully qualified internal hostname in a public tree.

It is read here rather than accepted from the caller, which is the part
that matters: the client names a verb and a datestamp and never a host,
a path, a binary or a signing key, and that is what stops a compromised
runner asking for a signature over something it fetched from a source of
its own choosing. A workflow variable would put it on the wrong side of
that line. It is validated as a hostname on the way in, since it becomes
part of an rsync remote specification.
2026-09-21 15:35:59 +01:00
Dave Page 42d2e55bcd Write the maintainer marker into published directories
Every directory under a release carries a CURRENT_MAINTAINER naming the
support address, which the PostgreSQL mirror network expects and which
the buildfarm wrote with an echo in each of nine job definitions. The
rehearsal produced a release directory without them.

It is a file beside the scripts now, so changing the support address is
one reviewed commit.
2026-09-21 15:30:49 +01:00
Dave Page f57e46c7c4 Fetch the release downloads, and keep the rehearsal confined
Two faults the first working rehearsal exposed.

rsync without -r reports 'skipping directory .' and transfers nothing,
so release-fetch created the directory, sorted no files and reported
success. The comment claiming -r was omitted on purpose was simply
wrong; the trees are excluded by pattern instead.

The auto-updater archives were being moved by release-fetch, which
writes them outside v<VERSION>. That contradicted the whole point of the
release-directory scope, which is that deleting one directory undoes it.
They are now autoupdate-publish's job, and it runs with the packages,
where the promotion stops being reversible anyway.
2026-09-21 15:28:58 +01:00
Dave Page 9a68105260 Name the staging server in full
PULL_HOST carried the short name, one of the SITE values never confirmed
against the servers, and the first real release-fetch failed on it. The
firewall now permits the pull and the host key is recorded on the
download server under both names; the fully qualified one is used here so
that what this file names and what known_hosts records cannot drift.
2026-09-21 15:27:06 +01:00
Dave Page 299a767b1f Make the promotion scope an explicit choice
Three settings rather than a boolean, because the steps differ in how
undoable they are and that is what somebody needs to choose between.

release-directory writes only /var/ftp/pgadmin4/v<VERSION>, so deleting
that directory undoes it completely; it is the only setting that leaves
the live repositories alone, and so the only one that can be rehearsed.
files goes on to copy packages into the production trees and re-sign
every index clients read. everything adds the website and the S3
archive, neither of which can be undone.

The default is the safe one, on the grounds that a full promotion should
be a deliberate choice rather than what happens if nobody reads the
form.
2026-09-21 15:09:03 +01:00
Dave Page fc6815ae70 Allow a promotion to stop after the files
The website update and the S3 archive are the two steps with no undo:
one writes to the website database, the other copies into a bucket whose
sync carries no --delete. files-only skips both, so a promotion can be
rehearsed against a throwaway version and the files deleted again before
the hourly sync carries them to the mirrors.
2026-09-21 15:06:24 +01:00
Dave Page 456b2ddefa Promote a tested candidate to the download server
The last piece, replacing pgadmin4-all-publish-qa-to-prod and the deb
and rpm promotion jobs, plus update-download-archive and
pgadmin4-website-create-release.

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: the release directory,
the downloads sorted into it, the packages into the live repository
trees, then the indices, the READMEs, the website and finally the S3
archive. The order is deliberate, since an interruption before the
indices leaves packages nothing points at, which is recoverable, whereas
the reverse is a repository describing packages that are not there.

Two changes to the wrapper it depends on.

release-fetch assumed the staging build was divided into docs, macos,
pip, source and windows. It is not: a staging build is flat, and it is
promotion that sorts the downloads into those directories, which the
buildfarm did with a series of scp commands carrying shell globs. That
sorting is now a table, the destination comes from each file's own name,
and an unrecognised file stops the publication rather than being dropped
silently. It also handles the auto-updater ZIPs, which replace the
previous release's rather than accumulating beside them, and which
nothing here had carried across at all.

rebuild-readme is new, and closes the gap left when the README generator
went in: nothing regenerated the production platform tables, only the
staging and snapshot ones. Those carry the release history, so they
change with every release.
2026-09-21 15:04:21 +01:00
Dave Page 9039065ee0 Check Gatekeeper before codesign --strict on the disk image
The macOS jobs failed on 'Disallowed xattr com.apple.FinderInfo', which
is not a signature problem: _create_dmg runs SetFile -a E on the bundle
to hide the .app extension in the disk image window, and that writes the
attribute --strict refuses to see.

What decides whether a user can open the application is Gatekeeper, so
spctl and stapler run first and are the pass or fail. --strict still
runs, and reports rather than fails, so that a future signature problem
is still visible and distinguishable from the attribute we put there
deliberately.
2026-09-21 14:53:08 +01:00
Dave Page 620f7b8064 Check the Windows, macOS and direct downloads too
The apt and yum halves proved a user can install from the repositories.
These cover everything else a user can download.

Windows is the most valuable, because it closes a gap rather than
confirming something already checked: nothing had ever run the installer,
and nothing verified the Authenticode signature on the pgAdmin4.exe
inside it. Make.bat's own check tested a stale %ERRORLEVEL%, and the
workflow step that claimed to cover it re-verified the installer instead.
That binary does not exist outside the installer until the installer has
run, so this is the only place it can be verified.

macOS mounts the published disk image rather than inspecting the bundle
that was just built, with the quarantine attribute a browser would set so
Gatekeeper is actually consulted. It also runs otool over the bundled
client binaries and fails if any still points at /opt/pgbuild, Homebrew
or /usr/local, which is what a dependency that was linked but not
relocated looks like.

The rest is deliberately shallow: the wheel is installed into a venv and
imported, the tarballs are checked for a couple of known paths, the PDF
and ePub are checked to be those formats and not truncated, and every
detached signature is verified from the published URL rather than on the
server, since that is the copy a user actually fetches.
2026-09-21 14:48:54 +01:00
Dave Page 3cd04b405e Do not use grep -q under pipefail
Both halves installed correctly and then failed the assertion that they
had: grep -q exits on the first match, dpkg -L and rpm -ql die of
SIGPIPE, and pipefail reports the pipeline as failed. The packages do
ship files under /usr/pgadmin4, as dpkg-deb -c on the published .deb
confirms.
2026-09-21 14:43:08 +01:00
Dave Page 8057624259 Correct two faults in the acceptance test itself
setup-web.sh ships in pgadmin4-web, not pgadmin4-server, so the apt jobs
installed correctly and then failed on an assertion about a file that
package never contained. The check is now that the package delivered
files under /usr/pgadmin4, which cannot be wrong about which package
owns what.

The yum jobs never got as far as installing: dnf install curl conflicts
with the curl-minimal already in those images, and curl was not needed
in the first place.
2026-09-21 14:40:47 +01:00
Dave Page 750d0630e0 Run the acceptance test steps under bash
The containers default to sh, which has no pipefail, so every step died
on its first line with 'Illegal option -o pipefail' before testing
anything.
2026-09-21 14:38:16 +01:00
Dave Page c23172af37 Install from the candidate, as a user would
Everything else checks that files exist, that indexes parse and that
signatures validate. All three can be true of a repository apt still
refuses, and both bugs the migration turned up were that shape: packages
one directory too deep, and noarch RPMs filed where no dnf client looks.
Neither failed a build.

So this inspects nothing. It follows the instructions in the repository
README, in a clean container, on all five apt platforms and all four yum
ones, and installs the package. No --allow-unauthenticated, no
--nogpgcheck, no pinning, so a wrong signature or an architecture missing
from Release fails the job.

It runs against a candidate rather than a snapshot because the
development server publishes its tree immediately, whilst the download
server sits behind an hourly sync to the mirrors that would otherwise be
most of what the test measured.

Promotion stays a human decision, so this gates nothing automatically; a
red result is simply a candidate that must not be promoted.
2026-09-21 14:36:09 +01:00
Dave Page 458b24b5f8 Do not write a literal ${{ }} in a comment
GitHub evaluates workflow expressions inside run blocks before the shell
sees them, comments included, so an empty one is a parse error: the
whole file is rejected with 'An expression was expected' and the
workflow cannot be dispatched or scheduled at all.

snapshot.yml has carried this since the review fixes landed, which would
have failed tonight's nightly, and release-candidate.yml inherited it.
2026-09-21 14:32:45 +01:00
Dave Page 4dc7a7bd7b Add release candidate builds, publishing to the development server
Phase one of moving the release pipeline off the buildfarm, replacing
the pgadmin4-*-build jobs. The same builds as the nightly snapshot,
calling the same workflows, but published to procyon through the
wrapper's staging verbs rather than to the download server.

The difference is what the tree is for. A snapshot is finished when it
is published; a candidate is published so that it can be tested, and
promoted afterwards. developer.pgadmin.org serves the staging tree over
HTTP the moment it is written, so a candidate's repositories can be
added and installed from immediately, whereas the download server sits
behind an hourly sync to the mirrors.

Never scheduled, and publishing is off unless asked for, because a
candidate exists only because somebody decided to make one.

Needs a STAGING_SSH_TARGET variable on the publishing environment; the
keys and the wrapper are already deployed on procyon.
2026-09-21 14:32:45 +01:00
Regina Obe 6312baa34e Fix dynamic type function not receiving correct state for collection row fields (#10398)
When a schema field uses a dynamic type function (type as a function)
and is rendered inside a collection (e.g. chain tasks), the function
always received the top-level schema data instead of the row-level
data. This caused the field to fall through to its default type,
ignoring the actual field values in the row.

Resolve the type callback's state against the field's own container
instead, mirroring what cell() already gets via its row argument: for a
field inside a collection row that is the row, and for a top-level
field the parent path is empty, so the whole schema data comes back as
before. Nested tabs and fieldsets carry no id, so they do not extend
the access path and are unaffected.

As well as the pg_timetable case that prompted this, it repairs the
expanded-row Definition forms that were already relying on row state
and silently getting the table's: the foreign key 'Referencing'
column list, the partition name control's attach/create switch, and
the column dialog's identity/generated constraint options.
2026-09-21 13:15:33 +01:00
Hari Prasad 5f56805162 Stop the Query Tool spinner whilst a password prompt is open (#10436)
When restoring saved Query Tool tabs, or switching to a connection with no
saved password, the backend replies with HTTP 428 and the 'Connect to server'
dialog opens, but obtaining_conn was left set to true whilst waiting for the
user. ConnectionBar renders MUI's CircularProgress from that flag, so every
affected tab kept two infinite CSS animations running at the display refresh
rate, costing around 30-35% CPU whilst completely idle.

obtaining_conn is now cleared when the 428 arrives and set again only whilst a
connection request is actually in flight, with a promptCallback threaded
through connectServer() so that password retries behave the same way. Along the
way this also fixes cancellation leaving obtaining_conn stuck on with the
Promise never settling, the New Connection dialog closing itself on a failed
connection rather than reporting the error, and duplicate entries being added
to the connection list when a role was an empty string rather than null.

Fixes #10386
2026-09-21 13:15:03 +01:00
Dave Page 7242be8be6 Do not write a literal ${{ }} in a comment
GitHub evaluates workflow expressions inside run blocks before the shell
sees them, comments included, so an empty one is a parse error: the file
is rejected with 'An expression was expected' and the workflow cannot be
dispatched or scheduled at all. Tonight's nightly would not have run.
2026-09-21 13:01:42 +01:00
dependabot[bot] e419d45930 Javascript dependency: bump html-react-parser from 5.2.17 to 6.1.7 in /web (#10336)
html-react-parser 6 is ESM only, and although webpack handles that without
complaint, Jest runs the suite as CommonJS and only transforms the packages
named in the allowlist in transformIgnorePatterns, so every suite that
reached a dialog died with "Must use import to load ES Module" from
domhandler. Add domhandler, domelementtype and htmlparser2 to the allowlist,
which is the smallest set that untangles the chain.

The allowlist approach fails closed and will keep catching us out on each
ESM-only bump; #10443 covers inverting it properly.
2026-09-21 12:58:44 +01:00
Dave Page 7c6fb89b82 Keep Make.bat stored with CRLF
.gitattributes normalises everything to LF, which rewrote the whole file
the first time this branch touched it and turned a two-line merge into a
whole-file conflict. Make.bat has been stored with CRLF since long before
that rule, so it is marked -text to leave it that way.
2026-09-21 12:51:22 +01:00
Dave Page a2439b78f5 Merge remote-tracking branch 'upstream/master' into worktree-qa-builds-on-actions
# Conflicts:
#	Make.bat
#	pkg/win32/README.md
2026-09-21 12:49:27 +01:00
Dave Page 14a796c427 Trigger the new build checks on master alone
The buildfarm-to-actions trigger existed so the branch could be tested
before it landed, which it no longer needs to be.

The four that filter on web/** also gain the message catalogue exclusion
that #10441 applied to the workflows already on master, for the same
reason: a .po change cannot break a package build, and check-translations
compiles the catalogues anyway.
2026-09-21 12:47:07 +01:00
Grégoire Bellon-Gervais 53729372a7 Include the authenticated user in the access log on logout (#10410)
Flask-Login has already cleared current_user by the time after_request
runs on a logout, so the X-Remote-User header, and with it the username
in the HTTP access log, was missing for exactly the requests that record
someone leaving. Stash the name on flask.g in before_request whilst it is
still available, and fall back to it when setting the header.

Signed-off-by: Grégoire Bellon-Gervais <gregoire.bellon-gervais@docaposte.fr>
2026-09-21 12:31:06 +01:00
dependabot[bot] 08f643e619 Javascript dependency: bump electron from 43.4.0 to 44.4.2 in /runtime (#10359) 2026-09-21 12:29:04 +01:00
Marvin A. Ruder 2bb59c0b10 Prefer the user's email address as the Gravatar identifier (#10416)
The user menu avatar was always hashed from the local pgAdmin username,
so a user whose Gravatar profile is registered against their email
address (commonly the case when the account came from an OAuth2
provider) got a default avatar, or worse, the avatar belonging to
somebody else who happens to share that username. The email address is
now used where one is known, falling back to the username otherwise.

Fixes #10415
2026-09-21 12:24:16 +01:00
dependabot[bot] e1ad058990 Javascript dependency: bump qs from 6.15.2 to 6.16.0 in /web (#10375)
The lockfile is regenerated here; Dependabot bumped only the resolutions entry, which left the lockfile inconsistent and failed every install.
2026-09-21 12:22:58 +01:00
dependabot[bot] 4e23684096 Javascript dependency: bump @xmldom/xmldom from 0.8.13 to 0.8.15 in /web (#10377)
The lockfile is regenerated here; Dependabot bumped only the resolutions entry, which left the lockfile inconsistent and failed every install.
2026-09-21 12:22:58 +01:00
dependabot[bot] c5e67bdc67 Javascript dependency: bump bignumber.js from 10.0.2 to 11.1.5 in /web (#10337) 2026-09-21 12:22:58 +01:00
dependabot[bot] 07d2f4dbc5 Javascript dependency: bump postcss-selector-parser from 7.1.1 to 7.1.5 in /web (#10369) 2026-09-21 12:22:58 +01:00
dependabot[bot] ca4fe9d08a Javascript dependency: bump fast-uri from 3.1.5 to 3.1.6 in /web (#10374) 2026-09-21 12:22:58 +01:00
dependabot[bot] 2d2402c822 Javascript dependency: bump browserslist from 4.28.2 to 4.28.8 in /web (#10376) 2026-09-21 12:22:58 +01:00
dependabot[bot] 9195037213 Javascript dependency: bump @humanfs/node from 0.16.7 to 0.16.8 in /web (#10378) 2026-09-21 12:22:58 +01:00
dependabot[bot] 9e31eebe26 Javascript dependency: bump js-yaml from 3.15.1 to 3.15.2 in /web (#10405) 2026-09-21 12:22:58 +01:00
dependabot[bot] ebbe4e36d8 Javascript dependency: bump baseline-browser-mapping from 2.10.16 to 2.11.21 in /web (#10406) 2026-09-21 12:22:58 +01:00
dependabot[bot] aca8796178 Javascript dependency: bump devalue from 5.8.1 to 5.9.2 in /web (#10431) 2026-09-21 12:22:58 +01:00
dependabot[bot] 27809a95ef Javascript dependency: bump the runtime-javascript-minor-and-patch group across 1 directory with 3 updates (#10411)
Bumps axios from 1.19.0 to 1.20.0, eslint from 10.8.1 to 10.10.0 and globals from 17.11.0 to 17.12.0.
2026-09-21 12:22:58 +01:00
dependabot[bot] 211f9bf1f3 Javascript dependency: bump @humanfs/node from 0.16.7 to 0.16.8 in /runtime (#10379) 2026-09-21 12:22:58 +01:00
dependabot[bot] df39657d6d Javascript dependency: bump fast-uri from 3.1.5 to 3.1.6 in /runtime (#10373) 2026-09-21 12:22:58 +01:00
dependabot[bot] 79f5bdfb40 Python dependency: bump the regression-python-minor-and-patch group in /web/regression with 2 updates (#10433)
Bumps psycopg from 3.3.4 to 3.3.5 and selenium from 4.48.0 to 4.49.0.
2026-09-21 12:22:58 +01:00
rlbxku1r 5c7f0bdb05 Update Japanese translation for v9.18 (#10423)
Bring the ja catalogue up to full coverage against the current message
template, taking it from 3313 to 3684 translated strings, and refine
eleven existing translations. The compiled catalogue is included and
matches the source exactly.
2026-09-21 12:17:53 +01:00
ShiPing 5060cd8bc0 Update Chinese (Simplified) translation (#10439)
Refresh the zh_Hans_CN catalogue against the current message template,
taking it from 2681 to 3684 translated strings, so every message in
messages.pot is now covered. A further 1419 existing translations were
revised for consistency and accuracy, including a handful that were
plainly wrong ("Drop Table/Relation" rendered as "add table/relation").
2026-09-21 12:14:02 +01:00
Dave Page d7a6a89df3 Do not run the whole matrix for a translation update (#10441)
Message catalogues live under web/, so every workflow filtering on web/**
fires for a .po change: both feature test suites, all three database test
suites, the container build and the tarball and wheel builds. None of them
can be broken by a translation, and check-translations already runs
msg-compile, which is the check that would catch a malformed catalogue.

The two JavaScript workflows need no change, their filters being narrow
enough to miss .po files already.
2026-09-21 12:05:05 +01:00
Dave Page 7dc7292d53 fix(pkg): stage the Windows Kerberos runtime next to libpq (#10440)
PostgreSQL 18 and later are now built with GSSAPI support, so the
libpq.dll we ship has a load-time dependency on gssapi64.dll where it
previously had none. The installer staged the MIT Kerberos runtime into
the python directory only, which left two problems.

pg_dump.exe, pg_dumpall.exe, pg_restore.exe and psql.exe live in the
runtime directory next to libpq.dll, and could no longer load it, so
backup and restore failed with a complaint that gssapi64.dll could not
be found. Adding the python directory to PATH worked around it.

Connections crashed with "exception: access violation writing
0x0000000000000000" against any server that accepts GSSAPI encryption,
which is most Linux and cloud builds, whilst servers that decline it
carried on working. We never staged krbcc64.dll, and on Windows krb5
statically defaults krb5_cc_dfl_ops to the CCAPI credential cache and
only switches away from it if that DLL loads, so its entry points were
left null and the first use of the default credential cache called
address zero. libpq asks for a credential cache on every connection
under the default gssencmode of prefer, and because psycopg falls back
to its ctypes binding on Windows, CPython caught the access violation
and reported it in the Connect to Server dialog rather than taking the
process down.

Stage the Kerberos runtime into the runtime directory instead, once,
adding krbcc64.dll and the ccapiserver.exe it drives. That serves the
client binaries directly, and serves libpq loaded by psycopg too, since
the runtime directory is prepended to PATH before Python is spawned and
a DLL's own directory is searched for its dependencies. Nothing is left
behind on upgrade, because the installer already deletes the python
directory before it installs. Whilst here, correct the build
instructions, which still claimed that the PostgreSQL build has no
Kerberos support, and fix a stray "L%" on the end of the pg_dumpall.exe
copy, which stopped a failure there aborting the build.

Issue #10428
2026-09-21 11:18:12 +01:00
703 changed files with 103854 additions and 37017 deletions

No files matched your search

+5
View File
@@ -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
+249 -10
View File
@@ -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
}
+431
View File
@@ -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__)'
+117 -2
View 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 }}
+5 -1
View File
@@ -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'
+1 -1
View File
@@ -12,7 +12,7 @@ on:
workflow_call:
push:
branches: [ "master", "buildfarm-to-actions" ]
branches: [ "master" ]
paths:
- 'pkg/helm/**'
- '.github/workflows/check-helm-build.yml'
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: Install Node modules
run: |
cd web
yarn install
yarn install --immutable
- name: Run the linter
run: |
+8 -1
View File
@@ -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'
+8
View File
@@ -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'
+5 -1
View File
@@ -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'
+13 -5
View File
@@ -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'
+454
View File
@@ -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
+54
View File
@@ -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
+439
View File
@@ -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 }}
+9 -1
View File
@@ -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
+9 -1
View File
@@ -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
+1 -1
View File
@@ -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'
+60
View File
@@ -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'
+13 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+91 -17
View File
@@ -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
-58
View File
@@ -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 }}
+16 -2
View File
@@ -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
#########################################################################
+612 -507
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
+29
View File
@@ -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.
+29
View File
@@ -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.
+7 -5
View File
@@ -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.
+13 -6
View File
@@ -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.
+3 -2
View File
@@ -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
+102
View File
@@ -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.
+9 -3
View File
@@ -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
+133 -16
View File
@@ -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:
+9 -2
View File
@@ -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:
+2 -2
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+9
View File
@@ -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
+1
View File
@@ -26,6 +26,7 @@ of database objects.
developer_tools
processes
pgagent
pgtimetable_chains
contributions
release_notes
licence
+77 -1
View File
@@ -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
===============
+43 -41
View File
@@ -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 |
+48 -2
View File
@@ -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.
+11 -4
View File
@@ -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
****************************
+1
View File
@@ -39,6 +39,7 @@ node, and select *Create Cast...*
publication_dialog
schema_dialog
sequence_dialog
statistics_dialog
subscription_dialog
synonym_dialog
trigger_function_dialog
+1 -1
View File
@@ -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. |
+3
View File
@@ -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)."
+93
View File
@@ -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)
-------------------------------------------
+239
View File
@@ -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
+12
View File
@@ -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.
+6 -4
View File
@@ -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. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
+1
View File
@@ -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
+196
View File
@@ -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.
+20
View File
@@ -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
+224 -13
View File
@@ -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
+113 -3
View File
@@ -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.
+93
View File
@@ -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.
+75 -21
View File
@@ -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
+4 -1
View File
@@ -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
===============
+3 -2
View File
@@ -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
+119 -113
View File
@@ -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
+2 -2
View File
@@ -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"]
+174
View File
@@ -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).
+3
View File
@@ -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.
+41 -8
View File
@@ -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
Binary file not shown.
+2
View File
@@ -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>
+63 -8
View File
@@ -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
+105 -18
View File
@@ -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}"
+4
View File
@@ -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

+14
View File
@@ -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

+72
View File
@@ -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"
]
}
}
+59
View File
@@ -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
+1 -1
View File
@@ -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
+215
View File
@@ -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])
+3
View File
@@ -0,0 +1,3 @@
Support: pgadmin-support@lists.postgresql.org
Website: https://www.pgadmin.org/
Tracker: https://github.com/pgadmin-org/pgadmin4/issues
+112 -22
View File
@@ -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.
+67
View File
@@ -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
-52
View File
@@ -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
+14 -9
View File
@@ -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))
+512 -53
View File
@@ -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),
Loaded 100 of 703 files, more files were not shown because too many files have changed in this diff. Show more