From 09fae1969f01a649e6799b8fa407b5aaf9db81f2 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Fri, 9 Oct 2026 14:03:18 +0000 Subject: [POC PATCH v2 5/6] Add JSON format support for the server configuration file The configuration file format is specific to PostgreSQL and has no place for structured values, which makes it hard to extend and hard to process with standard tooling. Allow the root configuration file to be a JSON document instead. postgresql.json is picked up next to where postgresql.conf would be, or named explicitly with --config-file. Top-level keys other than the reserved ones are settings; arrays are joined into list values. An "include" key loads further JSON documents or legacy .conf files, later files overriding earlier ones. The parsed settings feed into the existing ConfigVariable list, so reload semantics, ALTER SYSTEM and pg_file_settings work unchanged. The "hba", "ident" and "hosts" keys of the document hold the client authentication configuration, in the format the JSON files named by hba_file, ident_file and hosts_file use; those parameters cannot be set with a JSON root, from any source, since the sections take their place. pg_ctl and pg_upgrade recognize a configuration-only directory by postgresql.json as well. --- doc/src/sgml/client-auth.sgml | 28 +- doc/src/sgml/config.sgml | 152 ++++++++++- doc/src/sgml/runtime.sgml | 5 +- src/backend/libpq/be-secure-common.c | 22 +- src/backend/libpq/be-secure-openssl.c | 6 +- src/backend/libpq/hba.c | 27 +- src/backend/postmaster/postmaster.c | 7 +- src/backend/utils/adt/hbafuncs.c | 21 +- src/backend/utils/init/postinit.c | 2 +- src/backend/utils/misc/config_json.c | 296 ++++++++++++++++++---- src/backend/utils/misc/guc.c | 115 ++++++++- src/bin/pg_ctl/pg_ctl.c | 11 +- src/bin/pg_upgrade/option.c | 12 +- src/include/libpq/hba.h | 2 + src/include/libpq/libpq.h | 1 + src/include/utils/config_json.h | 3 + src/test/authentication/t/008_json_hba.pl | 104 +++++++- src/test/modules/Makefile | 1 + src/test/modules/config_json/.gitignore | 4 + src/test/modules/config_json/Makefile | 14 + src/test/modules/config_json/meson.build | 12 + src/test/modules/config_json/t/001_guc.pl | 292 +++++++++++++++++++++ src/test/modules/meson.build | 1 + src/test/ssl/t/005_json_hosts.pl | 43 +++- 24 files changed, 1091 insertions(+), 90 deletions(-) create mode 100644 src/test/modules/config_json/.gitignore create mode 100644 src/test/modules/config_json/Makefile create mode 100644 src/test/modules/config_json/meson.build create mode 100644 src/test/modules/config_json/t/001_guc.pl diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml index 2e35a1bdee9..0b7d55755cb 100644 --- a/doc/src/sgml/client-auth.sgml +++ b/doc/src/sgml/client-auth.sgml @@ -71,8 +71,9 @@ directory is initialized by . It is possible to place the authentication configuration file elsewhere, however; see the configuration parameter. - The rules can also be written as a JSON document, in the tree format - described in . + When the server configuration file is a JSON document (see ), the rules are instead part of that document, + in the tree format described in . @@ -902,6 +903,10 @@ local db1,db2,@demodbs all scram-sha-256 pg_hba.json + + postgresql.json + hba section + The client authentication rules can also be given as a JSON document. @@ -910,7 +915,11 @@ local db1,db2,@demodbs all scram-sha-256 the file is read as such a document instead of in the format described above. The document is an object whose hba key holds the rules; it may also have an include key, - described below, and any other key is an error. + described below, and any other key is an error. When the server + configuration file itself is a JSON document (see ), the hba key of that + document holds the rules instead, and hba_file + cannot be set. @@ -1417,6 +1426,10 @@ omicron bryanh guest1 pg_ident.json + + postgresql.json + ident section + The user name maps can also be given as a JSON document, in the same @@ -1425,9 +1438,12 @@ omicron bryanh guest1 names a file whose name ends in .json (customarily pg_ident.json), it is read as a document whose ident key holds the maps, with an optional - include key as described there. The value is an - object with two optional keys. maps is an object - whose keys are map names and whose values are the maps. A map is an object from + include key as described there. When the server + configuration file is a JSON document, its ident + key holds the maps instead, and ident_file cannot + be set. The value is an object with two optional keys. + maps is an object whose keys are map names and + whose values are the maps. A map is an object from system user name to database user name; the value is a string, or an array of strings when the same system user may connect as several database users, since JSON keys are unique. Every pair is a mapping diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 9d3d00a7d40..b1a1226bfb9 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -159,6 +159,13 @@ shared_buffers = 128MB all but the last one are ignored. + + The configuration file can alternatively be a JSON document named + postgresql.json; see . + Everything said about postgresql.conf in this + chapter applies to it as well, except for the syntax. + + Parameters set in this way provide default values for the cluster. The settings seen by active sessions will be these values unless they @@ -499,6 +506,136 @@ include_dir 'conf.d' control is another good practice to consider.) + + + JSON Configuration File + + + + postgresql.json + + Instead of postgresql.conf, the main server + configuration file can be a JSON document. Any file whose name ends + in .json is read as JSON, and a file named + postgresql.json in the data directory is found + automatically where postgresql.conf would be. + If both postgresql.json and + postgresql.conf exist and no configuration file + is named explicitly, the server refuses to start rather than guess; + pass (or -c config_file=) + to select one. + + + + The document is a single object. Its keys are parameter names, with + four exceptions: include, described below, and + hba, ident and + hosts, which are reserved for sections holding the + client authentication configuration. The hba + section is described in , the + ident section in , and the hosts + section, which configures SSL per host name, in . Key order does not matter. + A parameter value is a string, a number, a boolean, or an array of + those. Numbers and booleans are written as they would be in + postgresql.conf, so 1500 and + "1500" mean the same, and units still go in a + string ("7MB"). An array becomes a comma-separated + list, which is how list parameters such as + search_path are written; elements of parameters + that quote their list members, such as search_path, + are quoted as needed, while for other list parameters an element may + not contain a comma. Objects and null are not + valid parameter values, and a key that appears twice in the same + object is an error, since JSON leaves its meaning undefined. The file + is read as UTF-8; non-ASCII text is written directly, as + \u escapes are accepted only for characters up to + U+007F. Parameter values are passed on as the bytes written, without + conversion, exactly as values from postgresql.conf + are, so a non-ASCII value is only meaningful in databases whose + encoding is UTF-8. + + + + The include key names further files to read. It + is a string, an object with a file key and an + optional boolean optional key, or an array of + either. A file name that is not absolute is taken relative to the + directory of the including file. The last component of the name may + contain the wildcards * and ?, + in which case all matching files are read in file name order (by C + locale rules). A file marked optional may be + missing, and an optional pattern may match nothing; otherwise either + is an error. Files whose + names end in .json are JSON documents of the same + form as the root, and can themselves include files; any other file + is read with the postgresql.conf syntax and can + only set parameters. + + + + Included files are processed after the parameters of the document + that includes them, and in the order listed, so a setting in an + included file overrides the same setting in the including file, and a + later include overrides an earlier one. This is the same as placing + the include directives at the end of a + postgresql.conf. + + + + The parameters , + and + cannot be set when the configuration file is JSON, neither in the + document nor in an included file, in + postgresql.auto.conf or on the command line, + since the client authentication configuration is given in the + reserved sections of the document instead. An existing + pg_hba.conf, pg_ident.conf + or pg_hosts.conf can be imported into the + corresponding section. + postgresql.auto.conf is read after a JSON root + exactly as it is after postgresql.conf, so + ALTER SYSTEM works unchanged. + + + + A short example: + +{ + "include": [ + "shared.conf", + {"file": "conf.d/*.json", "optional": true} + ], + "listen_addresses": ["localhost", "10.0.0.5"], + "port": 5433, + "shared_buffers": "4GB", + "work_mem": "64MB", + "log_connections": "all", + "enable_seqscan": false, + "hba": {"children": [{"import": "pg_hba.conf"}]} +} + + Errors found in a JSON configuration file name the file and line, and + the location within the document in a path notation such as + $.work_mem, $.include[1] or + $["pg_stat_statements.max"]. + The message, followed by the location, appears in the + error column of pg_file_settings, + whose sourcefile and + sourceline columns point at the JSON file + for each setting. As with postgresql.conf, a + file with errors stops the server from starting, and during a reload + it is logged and the previous settings are kept; a bad setting does + not stop the authentication sections from being reloaded, though. + On platforms where each new session reads the authentication + configuration again when it starts (Windows), an invalid + authentication section, or a file that cannot be read as a whole, + such as one with a JSON syntax error or a missing include, also makes + new connections fail until it is fixed, as an invalid + pg_hba.conf does; a bad setting does not. + + @@ -543,7 +680,11 @@ include_dir 'conf.d' Specifies the main server configuration file - (customarily called postgresql.conf). + (customarily called postgresql.conf, or + postgresql.json for the JSON format described + in ). When not set, the server looks + for either file in the data directory and fails to start if both + are present. This parameter can only be set on the postgres command line. @@ -562,7 +703,8 @@ include_dir 'conf.d' pg_hba.json for the JSON format described in ; a file whose name ends in .json is read as JSON). - This parameter can only be set at server start. + This parameter can only be set at server start, and cannot be set + when the main configuration file is a JSON document. @@ -579,7 +721,8 @@ include_dir 'conf.d' (customarily called pg_ident.conf, or pg_ident.json for the JSON format; a file whose name ends in .json is read as JSON). - This parameter can only be set at server start. + This parameter can only be set at server start, and cannot be set + when the main configuration file is a JSON document. See also . @@ -597,7 +740,8 @@ include_dir 'conf.d' (customarily called pg_hosts.conf, or pg_hosts.json for the JSON format; a file whose name ends in .json is read as JSON). - This parameter can only be set at server start. See also + This parameter can only be set at server start, and cannot be set + when the main configuration file is a JSON document. See also . diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml index a0936163062..c8f602482de 100644 --- a/doc/src/sgml/runtime.sgml +++ b/doc/src/sgml/runtime.sgml @@ -2708,7 +2708,10 @@ openssl x509 -req -in server.csr -text -days 365 \ names a file whose name ends in .json (customarily pg_hosts.json), it is read as a document whose hosts key holds the entries, with an optional - include key as described there: + include key as described there. When the server + configuration is a JSON document (see ), + hosts_file cannot be set, and the entries come from + the hosts section of that document instead: "hosts": { "import": ["pg_hosts.conf"], diff --git a/src/backend/libpq/be-secure-common.c b/src/backend/libpq/be-secure-common.c index ad7defb97eb..d6fb3d1bcda 100644 --- a/src/backend/libpq/be-secure-common.c +++ b/src/backend/libpq/be-secure-common.c @@ -365,7 +365,8 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) * load_hosts_json * * The JSON counterpart of load_hosts(): the entries come from the "hosts" - * sections of the JSON documents of hosts_file. No section anywhere is the + * sections of the JSON documents, those of hosts_file or of the server + * configuration file. No section anywhere is the * same as a missing file, a section without entries the same as an empty * one. The documents and the compiler's scratch data are temporary; the * entries themselves are allocated in the caller's context, as the ones @@ -449,8 +450,9 @@ load_hosts_json(List **hosts, char **err_msg) * the hosts configuration failed to load, the err_msg variable may have more * information in case it was passed as non-NULL. * - * When hosts_file names a JSON file, the entries come from its "hosts" - * sections instead; see load_hosts_json(). + * When hosts_file names a JSON file, or the server configuration is a JSON + * document, the entries come from its "hosts" sections instead; see + * load_hosts_json(). */ HostsFileLoadResult load_hosts(List **hosts, char **err_msg) @@ -531,6 +533,20 @@ load_hosts(List **hosts, char **err_msg) return HOSTSFILE_LOAD_OK; } +/* + * hosts_source_name + * + * Name of the file the hosts entries come from, for messages: the server + * configuration file when its "hosts" section replaces hosts_file. + */ +const char * +hosts_source_name(void) +{ + const char *source = config_json_auth_source(HostsFileName); + + return source != NULL ? source : HostsFileName; +} + /* * apply_hosts_settings * diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c index 95163955872..3812eafc5fa 100644 --- a/src/backend/libpq/be-secure-openssl.c +++ b/src/backend/libpq/be-secure-openssl.c @@ -236,7 +236,7 @@ be_tls_init(bool isServerStart) { ereport(isServerStart ? FATAL : LOG, errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("could not load file \"%s\": %s", HostsFileName, + errmsg("could not load file \"%s\": %s", hosts_source_name(), err_msg ? err_msg : "unknown error")); goto error; } @@ -382,7 +382,7 @@ be_tls_init(bool isServerStart) errmsg("no SSL configurations loaded"), /*- translator: The two %s contain filenames */ errhint("If ssl_sni is enabled then add configuration to file \"%s\", else file \"%s\".", - HostsFileName, "postgresql.conf")); + hosts_source_name(), ConfigFileName)); goto error; } @@ -685,7 +685,7 @@ init_host_context(HostsLine *host, bool isServerStart, bool *hasWarned) "Set \"%s\" to \"off\" to make use of the hook " "that is currently installed, or remove the hook " "and use per-host passphrase commands in \"%s\".", - "ssl_sni", HostsFileName)); + "ssl_sni", hosts_source_name())); *hasWarned = true; } diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c index 8ed673efc98..ebc12c4983c 100644 --- a/src/backend/libpq/hba.c +++ b/src/backend/libpq/hba.c @@ -2501,6 +2501,31 @@ check_hba(Port *port) port->hba = hba; } +/* + * hba_source_name + * Name of the file the hba rules come from, for messages: the server + * configuration file when its "hba" section replaces hba_file. + */ +const char * +hba_source_name(void) +{ + const char *source = config_json_auth_source(HbaFileName); + + return source != NULL ? source : HbaFileName; +} + +/* + * ident_source_name + * Name of the file the user name maps come from, for messages. + */ +const char * +ident_source_name(void) +{ + const char *source = config_json_auth_source(IdentFileName); + + return source != NULL ? source : IdentFileName; +} + /* * Read the config file and create a List of HbaLine records for the contents. * @@ -2612,7 +2637,7 @@ load_hba(void) ereport(LOG, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("configuration file \"%s\" contains no entries", - HbaFileName))); + hba_source_name()))); ok = false; } diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index f53aa9c404d..440f98b3ad4 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -1347,7 +1347,7 @@ PostmasterMain(int argc, char *argv[]) */ ereport(FATAL, /* translator: %s is a configuration file */ - (errmsg("could not load %s", HbaFileName))); + (errmsg("could not load %s", hba_source_name()))); } if (!load_ident()) { @@ -2037,11 +2037,12 @@ process_pm_reload_request(void) if (!load_hba()) ereport(LOG, /* translator: %s is a configuration file */ - (errmsg("%s was not reloaded", HbaFileName))); + (errmsg("%s was not reloaded", hba_source_name()))); if (!load_ident()) ereport(LOG, - (errmsg("%s was not reloaded", IdentFileName))); + /* translator: %s is a configuration file */ + (errmsg("%s was not reloaded", ident_source_name()))); #ifdef USE_SSL /* Reload SSL configuration as well */ diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c index 102db22f85d..336c8cdee16 100644 --- a/src/backend/utils/adt/hbafuncs.c +++ b/src/backend/utils/adt/hbafuncs.c @@ -427,7 +427,8 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, * fill_hba_view * Read the hba configuration and fill the tuplestore with view records. * - * The rules come from hba_file, in either format. + * The rules come from hba_file, in either format, or from the "hba" + * section of the configuration file when that is a JSON document. */ static void fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) @@ -463,7 +464,7 @@ fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - HbaFileName), + hba_source_name()), errdetail_internal("%s", file_err))); if (!tokenize_hba_json(documents, &hba_lines, DEBUG3, &file_err)) { @@ -471,7 +472,7 @@ fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - HbaFileName), + hba_source_name()), errdetail_internal("%s", file_err))); } } @@ -613,7 +614,8 @@ fill_ident_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, * fill_ident_view * Read the user name maps and fill the tuplestore with view records. * - * The maps come from ident_file, in either format. + * The maps come from ident_file, in either format, or from the "ident" + * section of the configuration file when that is a JSON document. */ static void fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) @@ -643,7 +645,7 @@ fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - IdentFileName), + ident_source_name()), errdetail_internal("%s", file_err))); if (!tokenize_ident_json(documents, &ident_lines, DEBUG3, &file_err)) { @@ -651,7 +653,7 @@ fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - IdentFileName), + ident_source_name()), errdetail_internal("%s", file_err))); } } @@ -839,7 +841,8 @@ fill_hosts_entry_rows(Tuplestorestate *tuple_store, TupleDesc tupdesc, * Read the SNI hosts configuration and fill the tuplestore with view * records. * - * The entries come from hosts_file, in either format. + * The entries come from hosts_file, in either format, or from the "hosts" + * section of the configuration file when that is a JSON document. */ static void fill_hosts_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) @@ -871,14 +874,14 @@ fill_hosts_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - HostsFileName), + hosts_source_name()), errdetail_internal("%s", file_err))); entries = parse_hosts_json(documents, DEBUG3, &found, &file_err); if (file_err != NULL) ereport(ERROR, (errcode(ERRCODE_CONFIG_FILE_ERROR), errmsg("could not load configuration file \"%s\"", - HostsFileName), + hosts_source_name()), errdetail_internal("%s", file_err))); foreach(lc, entries) diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c index 8b6ea195eca..4de2b6f391c 100644 --- a/src/backend/utils/init/postinit.c +++ b/src/backend/utils/init/postinit.c @@ -237,7 +237,7 @@ PerformAuthentication(Port *port) */ ereport(FATAL, /* translator: %s is a configuration file */ - (errmsg("could not load %s", HbaFileName))); + (errmsg("could not load %s", hba_source_name()))); } if (!load_ident()) diff --git a/src/backend/utils/misc/config_json.c b/src/backend/utils/misc/config_json.c index e5117b06d1f..06b20897474 100644 --- a/src/backend/utils/misc/config_json.c +++ b/src/backend/utils/misc/config_json.c @@ -3,13 +3,19 @@ * config_json.c * Loader for JSON-format configuration files. * - * A JSON client authentication file, named by hba_file, ident_file or - * hosts_file in place of the legacy file, is one object holding a single - * section ("hba", "ident" or "hosts") and, optionally, an "include" key - * naming further files like it. We parse the document with the common - * JSON parser into a small tree that keeps key order and source - * positions, resolve includes, and hand back the documents in load order, - * so that the loader of the section can pick out its parts. + * A JSON configuration root is one object. The keys "include", "hba", + * "ident" and "hosts" are reserved; every other key is a GUC. We parse + * the document with the common JSON parser into a small tree that keeps + * key order and source positions, resolve includes, and hand back two + * things: the GUC settings as a ConfigVariable list (the same shape the + * postgresql.conf parser produces) and, separately, the documents in load + * order, so that the loaders of the reserved sections can pick out their + * parts. + * + * The reserved sections can also live in JSON files of their own, named + * by hba_file, ident_file and hosts_file in place of the legacy files. + * Such a file holds one section and includes of files like it, nothing + * else; the section loaders get its documents the same way. * * Errors about a value carry the value's location as error context: its * path in the document, such as $.include[1] or $["pg_stat_statements.max"], @@ -52,8 +58,21 @@ typedef struct JsonConfParseState char *err_path; /* where, for a semantic failure */ } JsonConfParseState; +/* reserved top-level keys, which are not settings */ +static const char *const reserved_keys[] = { + "include", "hba", "ident", "hosts", NULL +}; + +/* + * Settings that name a client authentication file. With a JSON root the + * reserved sections take their place, so these cannot be set at all. + */ +static const char *const rejected_settings[] = { + "hba_file", "ident_file", "hosts_file", NULL +}; + static bool load_document(const char *path, const char *calling_file, - bool strict, int depth, int elevel, + bool strict, int depth, int elevel, bool settings, const char *section, ConfigVariable **head_p, ConfigVariable **tail_p, List **documents_p, JsonConfNode *directive); @@ -368,6 +387,22 @@ config_is_json(const char *filename) return dot != NULL && strcmp(dot, ".json") == 0; } +/* + * Is this a setting that cannot be set at all when the configuration file + * is JSON? Those that name a client authentication file are replaced by + * the reserved sections. + */ +bool +config_json_rejected_setting(const char *name) +{ + for (int i = 0; rejected_settings[i] != NULL; i++) + { + if (guc_name_compare(name, rejected_settings[i]) == 0) + return true; + } + return false; +} + /* Look up a key in an object node; NULL if absent or not an object. */ JsonConfNode * config_json_get(JsonConfNode *obj, const char *key) @@ -499,17 +534,48 @@ config_json_scalar_text(JsonConfNode *node) /* - * Error reporting. - * - * Problems are reported at elevel, and recorded as ConfigVariable error - * items in the shape ParseConfigFp produces for a broken file, so that - * the caller can take the first message out of the list. + * Conversion of a document tree into ConfigVariable items. + */ + +static bool +is_reserved_key(const char *key) +{ + for (int i = 0; reserved_keys[i]; i++) + if (strcmp(key, reserved_keys[i]) == 0) + return true; + return false; +} + +/* + * Append one setting to the list, in the shape ParseConfigFp produces. + * The item owns copies of all its strings, since FreeConfigVariables + * frees them while the document tree may live on. */ +static void +append_item(ConfigVariable **head_p, ConfigVariable **tail_p, + const char *name, const char *value, const char *file, int line) +{ + ConfigVariable *item = palloc_object(ConfigVariable); + + item->name = pstrdup(name); + item->value = pstrdup(value); + item->errmsg = NULL; + item->filename = pstrdup(file); + item->sourceline = line; + item->ignore = false; + item->applied = false; + item->next = NULL; + if (*head_p == NULL) + *head_p = item; + else + (*tail_p)->next = item; + *tail_p = item; +} /* * Report a problem with a node at elevel, with the node's location as - * context, and record it with the location appended. The message is - * translated here. + * context, and record it in the list with the location appended, so that + * pg_file_settings can show it. The message is translated here. */ static void json_setting_error(JsonConfNode *node, int elevel, ConfigVariable **head_p, @@ -679,11 +745,12 @@ config_json_setting_text(const char *name, JsonConfNode *node, * Process one include entry: a string, or an object with "file" and an * optional "optional" flag. The file name may carry wildcards in its last * component, in which case all matching files are loaded in name order and - * no match at all is fine. + * no match at all is fine. "settings" is as for load_document(). */ static bool process_include_entry(JsonConfNode *entry, const char *calling_file, - int depth, int elevel, const char *section, + int depth, int elevel, bool settings, + const char *section, ConfigVariable **head_p, ConfigVariable **tail_p, List **documents_p) { @@ -827,7 +894,8 @@ process_include_entry(JsonConfNode *entry, const char *calling_file, for (int i = 0; i < nnames; i++) { if (!load_document(names[i], calling_file, true, depth + 1, elevel, - section, head_p, tail_p, documents_p, entry)) + settings, section, head_p, tail_p, documents_p, + entry)) ok = false; } return ok; @@ -835,18 +903,26 @@ process_include_entry(JsonConfNode *entry, const char *calling_file, abs = AbsoluteConfigLocation(spec, calling_file); return load_document(abs, calling_file, !optional, depth + 1, elevel, - section, head_p, tail_p, documents_p, entry); + settings, section, head_p, tail_p, documents_p, entry); } /* - * Load one document and everything it includes, appending them to - * *documents_p in that order: the document first, then its includes. - * The document may hold the given section and an "include" key, nothing - * else. + * Load one document and everything it includes. + * + * The document's own settings are appended first and its includes after + * them. Since the last occurrence of a setting wins when the list is + * applied, this makes an included file override the file that includes + * it, and later includes override earlier ones, matching the behavior of + * an include directive at the end of postgresql.conf. + * + * Without "settings", only the documents are wanted: the settings are not + * converted, so their errors are neither reported nor counted, and a + * legacy include, which can only hold settings, is only checked to exist. + * The result then says whether the document set itself could be read. */ static bool load_document(const char *path, const char *calling_file, bool strict, - int depth, int elevel, const char *section, + int depth, int elevel, bool settings, const char *section, ConfigVariable **head_p, ConfigVariable **tail_p, List **documents_p, JsonConfNode *directive) { @@ -903,11 +979,40 @@ load_document(const char *path, const char *calling_file, bool strict, FreeFile(fp); } - /* Only documents can be included; the root is known to be one. */ + /* A legacy file can only carry settings; hand it to the old parser. */ if (!config_is_json(path)) { - json_setting_error(directive, elevel, head_p, tail_p, - "included file \"%s\" is not a JSON file", path); + FILE *fp; + + if (section != NULL) + { + /* a client authentication document has no use for one */ + json_setting_error(directive, elevel, head_p, tail_p, + "included file \"%s\" is not a JSON file", + path); + return false; + } + + if (settings) + return ParseConfigFile(path, strict, calling_file, + directive ? directive->line : 0, + depth, elevel, head_p, tail_p); + + /* as ParseConfigFile() does for a file it cannot open */ + fp = AllocateFile(path, "r"); + if (fp != NULL) + { + FreeFile(fp); + return true; + } + ereport(elevel, + (errcode_for_file_access(), + errmsg("could not open configuration file \"%s\": %m", + path))); + record_config_file_error(psprintf("could not open file \"%s\"", path), + calling_file, + directive ? directive->line : 0, + head_p, tail_p); return false; } @@ -964,14 +1069,73 @@ load_document(const char *path, const char *calling_file, bool strict, forboth(k, doc->keys, v, doc->values) { const char *key = lfirst(k); - - /* a client authentication document holds one section */ - if (strcmp(key, "include") != 0 && strcmp(key, section) != 0) + JsonConfNode *val = lfirst(v); + char *text; + char *msg; + JsonConfNode *at; + ListCell *pk; + bool dup = false; + + if (section != NULL) { - json_setting_error(lfirst(v), elevel, head_p, tail_p, + /* a client authentication document holds one section */ + if (strcmp(key, "include") == 0 || strcmp(key, section) == 0) + continue; + json_setting_error(val, elevel, head_p, tail_p, "unrecognized key \"%s\"", key); ok = false; + continue; + } + + if (is_reserved_key(key) || !settings) + continue; + + /* + * The parser only catches a key repeated exactly, but parameter names + * are case-insensitive, and either occurrence winning silently would + * hide a mistake just the same. + */ + foreach(pk, doc->keys) + { + if (pk == k) + break; + if (!is_reserved_key(lfirst(pk)) && + guc_name_compare(lfirst(pk), key) == 0) + dup = true; + } + if (dup) + { + json_setting_error(val, elevel, head_p, tail_p, + "duplicate key \"%s\"", key); + ok = false; + continue; + } + + if (config_json_rejected_setting(key)) + { + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("parameter \"%s\" cannot be set with a JSON configuration file", + key), + errhint("With a JSON configuration file, client authentication is configured in its \"hba\", \"ident\" and \"hosts\" sections."), + config_json_errcontext(val))); + msg = psprintf(_("parameter \"%s\" cannot be set with a JSON configuration file"), + key); + record_config_file_error(config_json_error_text(val, msg), + val->file, val->line, head_p, tail_p); + ok = false; + continue; + } + + text = config_json_setting_text(key, val, &at, &msg); + + if (text == NULL) + { + json_setting_error(at, elevel, head_p, tail_p, "%s", msg); + ok = false; + continue; } + append_item(head_p, tail_p, key, text, val->file, val->line); } include = config_json_get(doc, "include"); @@ -979,8 +1143,8 @@ load_document(const char *path, const char *calling_file, bool strict, { if (include->type == JCN_STRING || include->type == JCN_OBJECT) { - if (!process_include_entry(include, path, depth, elevel, section, - head_p, tail_p, documents_p)) + if (!process_include_entry(include, path, depth, elevel, settings, + section, head_p, tail_p, documents_p)) ok = false; } else if (include->type == JCN_ARRAY) @@ -990,7 +1154,7 @@ load_document(const char *path, const char *calling_file, bool strict, foreach(lc, include->values) { if (!process_include_entry(lfirst(lc), path, depth, elevel, - section, head_p, tail_p, + settings, section, head_p, tail_p, documents_p)) ok = false; } @@ -1007,14 +1171,40 @@ load_document(const char *path, const char *calling_file, bool strict, } /* - * Load the documents of a client authentication file, the file first and - * then its includes in load order, for the loader of the section, which - * picks out its parts. On failure *err_msg carries the first recorded - * error text. + * Load a JSON configuration root. + * + * Settings are appended to the ConfigVariable list at *head_p and *tail_p, + * in the same form ParseConfigFile produces, including error records. + * Returns false if anything went wrong; with elevel below ERROR, all + * problems are reported rather than just the first. */ bool -config_json_load_section(const char *section, const char *filename, - int elevel, List **documents_p, char **err_msg) +config_json_load(const char *filename, int elevel, + ConfigVariable **head_p, ConfigVariable **tail_p) +{ + char *abs = AbsoluteConfigLocation(filename, NULL); + List *documents = NIL; + + return load_document(abs, NULL, true, CONF_FILE_START_DEPTH, elevel, + true, NULL, head_p, tail_p, &documents, NULL); +} + +/* + * Load the documents only, root first and then includes in load order, for + * the loaders of the reserved sections, which pick out their parts. On + * failure *err_msg carries the first recorded error text. + * + * Only errors that make the document set itself unreliable count: a file + * that cannot be read or parsed, or a failed include. With a NULL + * "section" the settings are not even looked at, so a bad one fails the + * settings reload and shows in pg_file_settings, but does not take the + * other sections down with it. With a section name, the documents are a + * client authentication file and its includes, which may hold nothing but + * that section, so any other key is an error. + */ +static bool +load_documents(const char *filename, const char *section, int elevel, + List **documents_p, char **err_msg) { ConfigVariable *head = NULL, *tail = NULL; @@ -1024,7 +1214,7 @@ config_json_load_section(const char *section, const char *filename, *err_msg = NULL; *documents_p = NIL; ok = load_document(abs, NULL, true, CONF_FILE_START_DEPTH, elevel, - section, &head, &tail, documents_p, NULL); + false, section, &head, &tail, documents_p, NULL); if (!ok) { for (ConfigVariable *item = head; item; item = item->next) @@ -1043,13 +1233,33 @@ config_json_load_section(const char *section, const char *filename, /* * Which JSON document holds a client authentication section, given the - * section's file parameter: the file itself when its name says it is - * JSON, or NULL for the legacy format. + * section's file parameter: the server configuration file when that is + * JSON, since its sections replace the files then; the file itself when + * its name says it is JSON; or NULL for the legacy format. */ const char * config_json_auth_source(const char *filename) { + if (ConfigFileName != NULL && config_is_json(ConfigFileName)) + return ConfigFileName; if (filename != NULL && config_is_json(filename)) return filename; return NULL; } + +/* + * Load the documents holding a client authentication section, for its + * loader: those of the server configuration file when that is JSON, else + * the JSON file named by the section's file parameter and its includes. + * The caller has checked with config_json_auth_source() that there is a + * JSON document to load. + */ +bool +config_json_load_section(const char *section, const char *filename, + int elevel, List **documents_p, char **err_msg) +{ + if (ConfigFileName != NULL && config_is_json(ConfigFileName)) + return load_documents(ConfigFileName, NULL, elevel, documents_p, + err_msg); + return load_documents(filename, section, elevel, documents_p, err_msg); +} diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c index 6f9e95f3534..2286f5f2d4e 100644 --- a/src/backend/utils/misc/guc.c +++ b/src/backend/utils/misc/guc.c @@ -48,12 +48,14 @@ #include "utils/acl.h" #include "utils/builtins.h" #include "utils/conffiles.h" +#include "utils/config_json.h" #include "utils/guc_tables.h" #include "utils/memutils.h" #include "utils/timestamp.h" #define CONFIG_FILENAME "postgresql.conf" +#define CONFIG_JSON_FILENAME "postgresql.json" #define HBA_FILENAME "pg_hba.conf" #define IDENT_FILENAME "pg_ident.conf" #define HOSTS_FILENAME "pg_hosts.conf" @@ -296,9 +298,17 @@ ProcessConfigFileInternal(GucContext context, bool applySettings, int elevel) ConfFileWithError = ConfigFileName; head = tail = NULL; - if (!ParseConfigFile(ConfigFileName, true, - NULL, 0, CONF_FILE_START_DEPTH, elevel, - &head, &tail)) + if (config_is_json(ConfigFileName)) + { + if (!config_json_load(ConfigFileName, elevel, &head, &tail)) + { + error = true; + goto bail_out; + } + } + else if (!ParseConfigFile(ConfigFileName, true, + NULL, 0, CONF_FILE_START_DEPTH, elevel, + &head, &tail)) { /* Syntax error(s) detected in the file, so bail out */ error = true; @@ -394,6 +404,29 @@ ProcessConfigFileInternal(GucContext context, bool applySettings, int elevel) if (item->ignore) continue; + /* + * With a JSON root, the settings naming client authentication files + * are rejected wherever they come from, an included legacy file or + * PG_AUTOCONF_FILENAME included; the JSON loader has already refused + * them in the documents themselves. + */ + if (config_is_json(ConfigFileName) && + config_json_rejected_setting(item->name)) + { + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("parameter \"%s\" cannot be set with a JSON configuration file", + item->name), + errhint("With a JSON configuration file, client authentication is configured in its \"hba\", \"ident\" and \"hosts\" sections."), + errcontext("line %d of configuration file \"%s\"", + item->sourceline, item->filename))); + item->errmsg = psprintf(_("parameter \"%s\" cannot be set with a JSON configuration file"), + item->name); + error = true; + ConfFileWithError = item->filename; + continue; + } + /* * Try to find the variable; but do not create a custom placeholder if * it's not there already. @@ -1679,9 +1712,10 @@ SelectConfigFiles(const char *userDoption, const char *progname) /* * Find the configuration file: if config_file was specified on the - * command line, use it, else use configdir/postgresql.conf. In any case - * ensure the result is an absolute path, so that it will be interpreted - * the same way by future backends. + * command line, use it, else look in configdir for postgresql.json or + * postgresql.conf, whichever exists. Having both is ambiguous, so we + * refuse to guess. In any case ensure the result is an absolute path, so + * that it will be interpreted the same way by future backends. */ if (ConfigFileName) { @@ -1690,9 +1724,39 @@ SelectConfigFiles(const char *userDoption, const char *progname) } else if (configdir) { - fname = guc_malloc(FATAL, - strlen(configdir) + strlen(CONFIG_FILENAME) + 2); - sprintf(fname, "%s/%s", configdir, CONFIG_FILENAME); + char *jsonname; + char *confname; + bool have_json; + bool have_conf; + + jsonname = guc_malloc(FATAL, + strlen(configdir) + strlen(CONFIG_JSON_FILENAME) + 2); + sprintf(jsonname, "%s/%s", configdir, CONFIG_JSON_FILENAME); + confname = guc_malloc(FATAL, + strlen(configdir) + strlen(CONFIG_FILENAME) + 2); + sprintf(confname, "%s/%s", configdir, CONFIG_FILENAME); + + have_json = (stat(jsonname, &stat_buf) == 0); + have_conf = (stat(confname, &stat_buf) == 0); + if (have_json && have_conf) + { + write_stderr("%s: both \"%s\" and \"%s\" exist in \"%s\"\n" + "Remove one of them, or specify the configuration file with --config-file.\n", + progname, CONFIG_JSON_FILENAME, CONFIG_FILENAME, + configdir); + goto fail; + } + + if (have_json) + { + fname = jsonname; + guc_free(confname); + } + else + { + fname = confname; + guc_free(jsonname); + } fname_is_malloced = false; } else @@ -1773,6 +1837,27 @@ SelectConfigFiles(const char *userDoption, const char *progname) */ ProcessConfigFile(PGC_POSTMASTER); + /* + * With a JSON root, the files the client authentication sections replace + * cannot be named. The configuration files have been checked; this + * catches the command line. + */ + if (config_is_json(ConfigFileName)) + { + const char *const names[] = {"hba_file", "ident_file", "hosts_file"}; + char **vars[] = {&HbaFileName, &IdentFileName, &HostsFileName}; + + for (int i = 0; i < lengthof(names); i++) + { + if (*vars[i] == NULL) + continue; + write_stderr("%s: parameter \"%s\" cannot be set with a JSON configuration file\n" + "With a JSON configuration file, client authentication is configured in its \"hba\", \"ident\" and \"hosts\" sections.\n", + progname, names[i]); + goto fail; + } + } + /* * If timezone_abbreviations wasn't set in the configuration file, install * the default value. We do it this way because we can't safely install a @@ -4713,6 +4798,18 @@ AlterSystemSetConfigFile(AlterSystemStmt *altersysstmt) errmsg("parameter \"%s\" cannot be changed", name))); + /* + * With a JSON root, the server would refuse the value at the next + * reload; refuse it now instead. + */ + if (value && config_is_json(ConfigFileName) && + config_json_rejected_setting(record->name)) + ereport(ERROR, + (errcode(ERRCODE_CANT_CHANGE_RUNTIME_PARAM), + errmsg("parameter \"%s\" cannot be set with a JSON configuration file", + record->name), + errhint("With a JSON configuration file, client authentication is configured in its \"hba\", \"ident\" and \"hosts\" sections."))); + /* * If a value is specified, verify that it's sane. */ diff --git a/src/bin/pg_ctl/pg_ctl.c b/src/bin/pg_ctl/pg_ctl.c index 199f6c55b4a..9090a5dc5f1 100644 --- a/src/bin/pg_ctl/pg_ctl.c +++ b/src/bin/pg_ctl/pg_ctl.c @@ -2137,10 +2137,17 @@ adjust_data_dir(void) if (pg_config == NULL) return; - /* If there is no postgresql.conf, it can't be a config-only dir */ + /* + * If there is neither postgresql.conf nor postgresql.json, it can't be a + * config-only dir + */ snprintf(filename, sizeof(filename), "%s/postgresql.conf", pg_config); if ((fd = fopen(filename, "r")) == NULL) - return; + { + snprintf(filename, sizeof(filename), "%s/postgresql.json", pg_config); + if ((fd = fopen(filename, "r")) == NULL) + return; + } fclose(fd); /* If PG_VERSION exists, it can't be a config-only dir */ diff --git a/src/bin/pg_upgrade/option.c b/src/bin/pg_upgrade/option.c index f01d2f92d95..d3fd3de2837 100644 --- a/src/bin/pg_upgrade/option.c +++ b/src/bin/pg_upgrade/option.c @@ -439,10 +439,18 @@ adjust_data_dir(ClusterInfo *cluster) /* Initially assume config dir and data dir are the same */ cluster->pgconfig = pg_strdup(cluster->pgdata); - /* If there is no postgresql.conf, it can't be a config-only dir */ + /* + * If there is neither postgresql.conf nor postgresql.json, it can't be a + * config-only dir + */ snprintf(filename, sizeof(filename), "%s/postgresql.conf", cluster->pgconfig); if ((fp = fopen(filename, "r")) == NULL) - return; + { + snprintf(filename, sizeof(filename), "%s/postgresql.json", + cluster->pgconfig); + if ((fp = fopen(filename, "r")) == NULL) + return; + } fclose(fp); /* If PG_VERSION exists, it can't be a config-only dir */ diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index 99452df98e0..6cac069cc46 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -202,6 +202,8 @@ typedef struct Port Port; extern bool load_hba(void); extern bool load_ident(void); +extern const char *hba_source_name(void); +extern const char *ident_source_name(void); extern const char *hba_authname(UserAuth auth_method); extern void hba_getauthmethod(Port *port); extern int check_usermap(const char *usermap_name, diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h index 49e4ae59449..af491061155 100644 --- a/src/include/libpq/libpq.h +++ b/src/include/libpq/libpq.h @@ -174,6 +174,7 @@ extern int run_ssl_passphrase_command(const char *cmd, const char *prompt, extern bool check_ssl_key_file_permissions(const char *ssl_key_file, bool isServerStart); extern HostsFileLoadResult load_hosts(List **hosts, char **err_msg); +extern const char *hosts_source_name(void); extern void apply_hosts_settings(HostsLine *host); #endif /* LIBPQ_H */ diff --git a/src/include/utils/config_json.h b/src/include/utils/config_json.h index 152e68521b6..88d2a5d04b1 100644 --- a/src/include/utils/config_json.h +++ b/src/include/utils/config_json.h @@ -47,7 +47,10 @@ typedef struct JsonConfNode } JsonConfNode; extern bool config_is_json(const char *filename); +extern bool config_json_rejected_setting(const char *name); +extern bool config_json_load(const char *filename, int elevel, + ConfigVariable **head_p, ConfigVariable **tail_p); extern const char *config_json_auth_source(const char *filename); extern bool config_json_load_section(const char *section, const char *filename, int elevel, diff --git a/src/test/authentication/t/008_json_hba.pl b/src/test/authentication/t/008_json_hba.pl index e8d72dd817a..38ebcf6b83c 100644 --- a/src/test/authentication/t/008_json_hba.pl +++ b/src/test/authentication/t/008_json_hba.pl @@ -1,7 +1,8 @@ # Copyright (c) 2026, PostgreSQL Global Development Group # Test the JSON format of the client authentication rules and user name -# maps, in the files named by hba_file and ident_file. +# maps: as files of their own named by hba_file and ident_file, and as +# sections of a JSON server configuration file. use strict; use warnings FATAL => 'all'; @@ -19,6 +20,7 @@ $node->init; my $datadir = $node->data_dir; my $hba_json = "$datadir/pg_hba.json"; my $ident_json = "$datadir/pg_ident.json"; +my $root = "$datadir/postgresql.json"; sub write_file { @@ -694,4 +696,104 @@ my $ret = $node->start(fail_ok => 1); is($ret, 0, 'hba document without rules fails to start'); ok($node->log_contains(qr/contains no entries/), 'no entries reported'); +############################################################################## +# Sections of a JSON server configuration file +############################################################################## + +# The same rules and maps as sections of postgresql.json, which pulls in +# the postgresql.conf written so far, except for the file parameters it +# replaces. +my $conf = PostgreSQL::Test::Utils::slurp_file("$datadir/postgresql.conf"); +$conf =~ s/^(hba_file|ident_file) = .*\n//mg; +write_file("$datadir/postgresql.conf", $conf); +unlink $hba_json, $ident_json; + +# $hba and $ident are the JSON texts of the sections; @include are +# further include entries, already JSON-quoted. +sub set_root +{ + my ($hba, $ident, @include) = @_; + my $includes = join(', ', '"postgresql.conf"', @include); + chomp($hba); + chomp($ident); + write_file($root, + qq({"include": [$includes],\n "hba": $hba,\n "ident": $ident}\n)); +} + +set_root(<<"EOF", qq({"maps": {"m": {"$sysuser": "alice"}}})); +{"children": [ + {"transport": "local", "databases": "template1", + "auth": {"method": "peer", "map": "m"}}, + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "auth": {"method": "scram-sha-256"}, + "settings": {"authentication_timeout": "8"}} +]} +EOF +$node->start(options => "-c config_file=$root"); + +$res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, regexp_replace(file_name, '.*/', ''), line_number, + auth_method, node_path + FROM pg_hba_file_rules ORDER BY rule_number}); +is( $res, + join( + "\n", + '1|postgresql.json|3|peer|$.hba.children[0]', + '2|postgresql.json|5|trust|$.hba.children[1]', + '3|postgresql.json|6|scram-sha-256|$.hba.children[2]'), + 'rules read from the hba section of the configuration file'); +$res = $node->safe_psql( + 'postgres', q{ + SELECT map_number, regexp_replace(file_name, '.*/', ''), line_number, + pg_username + FROM pg_ident_file_mappings ORDER BY map_number}); +is($res, "1|postgresql.json|9|alice", + 'maps read from the ident section of the configuration file'); +SKIP: +{ + skip 'peer authentication is not supported on this platform', 1 + unless $peer_supported; + $node->connect_ok('dbname=template1 user=alice', + 'peer through a map in the configuration file'); +} + +# A bad setting in an included file fails the settings reload, but not the +# hba rules changed along with it, also where each backend loads them +# itself. +write_file("$datadir/bad_setting.json", '{"work_mem": {"x": 1}}'); +set_root(<<"EOF", '{}', '"bad_setting.json"'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "auth": {"method": "scram-sha-256"}, + "settings": {"authentication_timeout": "9"}} +]} +EOF +{ + my $offset = -s $node->logfile; + + $node->reload; + $node->wait_for_log( + qr/"work_mem" must be a string, number, boolean, or array of these/, + $offset); + $node->connect_ok( + 'host=127.0.0.1 dbname=postgres user=alice', + 'hba rules reloaded despite a settings error', + sql => 'SHOW authentication_timeout', + expected_stdout => qr/^9s$/m); +} + +# a rejected reload names the configuration file +set_root('{"children": [{"users": "alice"}]}', '{}'); +$log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/postgresql\.json was not reloaded/, $log_offset); + +# missing hba section: startup fails +$node->stop; +write_file($root, qq({"include": ["postgresql.conf"]}\n)); +$ret = $node->start(fail_ok => 1, options => "-c config_file=$root"); +is($ret, 0, 'JSON configuration file without hba fails to start'); +ok($node->log_contains(qr/contains no entries/), 'no entries reported'); + done_testing(); diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 71a2e65ad70..b3e6a4babed 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -7,6 +7,7 @@ include $(top_builddir)/src/Makefile.global SUBDIRS = \ brin \ commit_ts \ + config_json \ delay_execution \ dummy_index_am \ dummy_seclabel \ diff --git a/src/test/modules/config_json/.gitignore b/src/test/modules/config_json/.gitignore new file mode 100644 index 00000000000..5dcb3ff9723 --- /dev/null +++ b/src/test/modules/config_json/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/src/test/modules/config_json/Makefile b/src/test/modules/config_json/Makefile new file mode 100644 index 00000000000..46524da428c --- /dev/null +++ b/src/test/modules/config_json/Makefile @@ -0,0 +1,14 @@ +# src/test/modules/config_json/Makefile + +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/config_json +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/config_json/meson.build b/src/test/modules/config_json/meson.build new file mode 100644 index 00000000000..65317554a0b --- /dev/null +++ b/src/test/modules/config_json/meson.build @@ -0,0 +1,12 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +tests += { + 'name': 'config_json', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_guc.pl', + ], + }, +} diff --git a/src/test/modules/config_json/t/001_guc.pl b/src/test/modules/config_json/t/001_guc.pl new file mode 100644 index 00000000000..da2d98f5b6d --- /dev/null +++ b/src/test/modules/config_json/t/001_guc.pl @@ -0,0 +1,292 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; +use File::Copy; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('json'); +$node->init; +my $datadir = $node->data_dir; +my $root = "$datadir/postgresql.json"; + +sub write_file +{ + my ($path, $content) = @_; + open my $fh, '>', $path or die "cannot write $path: $!"; + print $fh $content; + close $fh; +} + +# Errors the loader found, as pg_file_settings reports them. +my $errors_query = q{SELECT string_agg(error, E'\n') FROM pg_file_settings + WHERE error IS NOT NULL}; + +sub file_errors +{ + return $node->safe_psql('postgres', $errors_query); +} + +# The JSON root pulls in the postgresql.conf that initdb and Cluster.pm +# wrote (port, sockets, fsync), then adds its own settings. +write_file($root, <<'EOF'); +{ + "include": ["postgresql.conf", {"file": "conf.d/*.json", "optional": true}, {"file": "conf.d/*.conf", "optional": true}], + "work_mem": "7MB", + "enable_seqscan": false, + "max_stack_depth": 1500, + "search_path": ["a", "b"], + "hba": {"children": [{"import": "pg_hba.conf"}]} +} +EOF + +$node->start(options => "-c config_file=$root"); + +is($node->safe_psql('postgres', 'SHOW work_mem'), '7MB', 'string value'); +is($node->safe_psql('postgres', 'SHOW enable_seqscan'), + 'off', 'boolean value'); +is($node->safe_psql('postgres', 'SHOW max_stack_depth'), + '1500kB', 'number value'); +is($node->safe_psql('postgres', 'SHOW search_path'), + 'a, b', 'array value joined with commas'); + +my $res = $node->safe_psql( + 'postgres', + q{SELECT regexp_replace(sourcefile, '.*/', ''), sourceline, applied + FROM pg_file_settings WHERE name = 'work_mem'}); +is($res, 'postgresql.json|3|t', 'pg_file_settings provenance for JSON'); + +# include overrides includer, later include overrides earlier +mkdir "$datadir/conf.d"; +write_file("$datadir/conf.d/10-a.json", '{"work_mem": "8MB"}'); +write_file("$datadir/conf.d/20-b.json", '{"work_mem": "9MB"}'); +$node->reload; +$node->poll_query_until('postgres', + q{SELECT current_setting('work_mem') = '9MB'}) + or die 'reload did not apply conf.d'; +pass('later include wins'); + +# .conf include through the legacy parser +write_file("$datadir/conf.d/30-c.conf", "work_mem = '10MB'\n"); +$node->reload; +$node->poll_query_until('postgres', + q{SELECT current_setting('work_mem') = '10MB'}) + or die 'legacy include not applied'; +pass('.conf include via legacy parser'); + +# List parameters: quoting where the parameter expects it, plain +# otherwise. +write_file("$datadir/conf.d/40-d.json", + '{"search_path": ["My Schema", "public"], ' + . '"log_connections": ["receipt", "authentication"]}'); +$node->reload; +$node->poll_query_until('postgres', + q{SELECT current_setting('search_path') = '"My Schema", public'}) + or die 'quoted list not applied'; +pass('GUC_LIST_QUOTE elements quoted'); +is($node->safe_psql('postgres', 'SHOW log_connections'), + 'receipt, authentication', 'plain list stays plain'); +unlink "$datadir/conf.d/40-d.json"; + +# A bad setting is only a settings error. It does not keep the rest of +# the configuration from loading, so new connections keep working even +# where they read the files again (EXEC_BACKEND), as file_errors() shows. +write_file("$datadir/conf.d/41-e.json", + '{"log_connections": ["receipt,authentication"]}'); +like( + file_errors(), + qr/elements of "log_connections" must not contain commas \(node \$\.log_connections\[0\], line 1 of file/, + 'comma in plain list element rejected'); +unlink "$datadir/conf.d/41-e.json"; + +# A file that cannot be read as a whole is a different matter: with +# EXEC_BACKEND, new backends may fail to start while it is in place. +# Such checks run in a session opened beforehand. +my $session = $node->background_psql('postgres'); + +# duplicate key: error in view, reload keeps old values +write_file("$datadir/conf.d/50-f.json", + '{"work_mem": "1MB", "work_mem": "2MB"}'); +like( + $session->query_safe($errors_query), + qr/duplicate key "work_mem" \(node \$\.work_mem, line 1 of file ".*50-f\.json"\)/, + 'duplicate key reported with file and line'); +$node->reload; +$node->wait_for_log(qr/contains errors; no changes were applied/); +is($session->query_safe('SHOW work_mem'), '10MB', + 'bad reload keeps old value'); +unlink "$datadir/conf.d/50-f.json"; + +# Parameter names are case-insensitive, so this is a duplicate too. +write_file("$datadir/conf.d/51-f.json", + '{"work_mem": "1MB", "Work_Mem": "2MB"}'); +like( + file_errors(), + qr/duplicate key "Work_Mem" \(node \$\.Work_Mem, line 1 of file/, + 'duplicate key differing in case reported'); +unlink "$datadir/conf.d/51-f.json"; + +# nested object under a GUC key; a key that is not an identifier is +# written in brackets in the path +write_file("$datadir/conf.d/60-g.json", + '{"work_mem": {"x": 1}, "pg_stat_statements.max": [{}]}'); +$res = file_errors(); +like( + $res, + qr/"work_mem" must be a string, number, boolean, or array of these \(node \$\.work_mem,/, + 'nested object rejected with path'); +like( + $res, + qr/\(node \$\["pg_stat_statements\.max"\]\[0\],/, + 'key with a dot in brackets'); +unlink "$datadir/conf.d/60-g.json"; + +# The file-naming parameters are rejected with a JSON root, in the JSON +# documents, whatever the spelling, and in included legacy files. +foreach my $guc (qw(hba_file ident_file hosts_file HBA_File)) +{ + write_file("$datadir/conf.d/70-h.json", qq({"$guc": "some.conf"})); + like( + file_errors(), + qr/parameter "$guc" cannot be set with a JSON configuration file \(node \$\.$guc,/, + "$guc rejected"); +} +unlink "$datadir/conf.d/70-h.json"; +write_file("$datadir/conf.d/71-h.conf", "hba_file = 'some.conf'\n"); +like( + file_errors(), + qr/parameter "hba_file" cannot be set with a JSON configuration file/, + 'hba_file rejected in an included legacy file'); +unlink "$datadir/conf.d/71-h.conf"; + +# ALTER SYSTEM would write it to postgresql.auto.conf, which is read +# after the JSON root. +my ($ret, $stdout, $stderr) = + $node->psql('postgres', "ALTER SYSTEM SET hba_file = 'some.conf'"); +like( + $stderr, + qr/parameter "hba_file" cannot be set with a JSON configuration file/, + 'ALTER SYSTEM SET hba_file rejected'); + +# A wildcard that matches the including file itself is direct recursion, +# not a nesting depth problem. +write_file("$datadir/conf.d/80-i.json", '{"include": "*.json"}'); +$res = $session->query_safe($errors_query); +like($res, qr/configuration file recursion/, 'self-include rejected'); +unlike($res, qr/nesting depth/, 'self-include is not a depth error'); +unlink "$datadir/conf.d/80-i.json"; + +# \u escapes beyond ASCII would depend on the parsing process's encoding +write_file("$datadir/conf.d/81-j.json", '{"work_mem": "7\\u00e9MB"}'); +like( + $session->query_safe($errors_query), + qr/Unicode escape sequences above U\+007F are not supported \(line 1 of file ".*81-j\.json"\)/, + 'non-ASCII \\u escape rejected'); +unlink "$datadir/conf.d/81-j.json"; + +# Include entry forms: '?' wildcard, wildcard in a directory component, +# non-boolean "optional", unrecognized key. +mkdir "$datadir/extra"; +write_file("$datadir/extra/01-x.json", '{"work_mem": "11MB"}'); +write_file( + "$datadir/conf.d/82-k.json", + '{"include": ["../extra/0?-x.json", "../ex*/01-x.json", ' + . '{"file": "x.json", "optional": "yes"}, ' + . '{"file": "x.json", "foo": 1}]}'); +is( $session->query_safe( + q{SELECT count(*) FROM pg_file_settings + WHERE sourcefile LIKE '%/extra/01-x.json'}), + '1', + '? wildcard matches'); +$res = $session->query_safe($errors_query); +like( + $res, + qr/wildcards are only allowed in the file name part: "\.\.\/ex\*\/01-x\.json" \(node \$\.include\[1\],/, + 'wildcard in directory component rejected'); +like( + $res, + qr/"optional" must be a boolean \(node \$\.include\[2\]\.optional,/, + 'non-boolean optional rejected'); +like( + $res, + qr/unrecognized key "foo" \(node \$\.include\[3\]\.foo,/, + 'unrecognized include key rejected'); +unlink "$datadir/conf.d/82-k.json"; + +# A wildcard that matches nothing is an error unless optional. +write_file("$datadir/conf.d/83-l.json", + '{"include": ["*.nothing", {"file": "*.none", "optional": true}]}'); +$res = $session->query_safe($errors_query); +like( + $res, + qr/include pattern "\*\.nothing" matches no files \(node \$\.include\[0\],/, + 'unmatched pattern rejected'); +unlike($res, qr/\*\.none/, 'unmatched optional pattern accepted'); +unlink "$datadir/conf.d/83-l.json"; + +$session->quit; +$node->stop; + +# Root discovery: postgresql.json next to a postgresql.conf without an +# explicit config_file is ambiguous. +$ret = $node->start(fail_ok => 1); +is($ret, 0, 'both roots present fails'); +ok( $node->log_contains( + qr/both "postgresql.json" and "postgresql.conf" exist/), + 'ambiguity reported'); + +# Rename the legacy root away: the JSON root is found by itself. +rename("$datadir/postgresql.conf", "$datadir/cluster.conf") or die $!; +my $json = slurp_file($root); +$json =~ s/"postgresql\.conf"/"cluster.conf"/; +write_file($root, $json); +$node->start; +is($node->safe_psql('postgres', 'SHOW config_file'), + $root, 'postgresql.json discovered as root'); +$node->stop; + +# hba_file on the command line is rejected as well +$ret = $node->start(fail_ok => 1, options => '-c hba_file=some.conf'); +is($ret, 0, 'hba_file on the command line fails'); +ok( $node->log_contains( + qr/parameter "hba_file" cannot be set with a JSON configuration file/), + 'hba_file on the command line reported'); + +# pg_ctl recognizes a configuration-only directory by postgresql.json, and +# asks the server for the data directory. +my $confdir = PostgreSQL::Test::Utils::tempdir; +my $jdatadir = $datadir =~ s/\\/\\\\/gr; +write_file("$confdir/postgresql.json", + qq({"data_directory": "$jdatadir", + "include": ["$jdatadir/cluster.conf"], + "hba": {"children": [{"import": "$jdatadir/pg_hba.conf"}]}}\n)); +copy("$datadir/pg_hba.conf", $confdir) or die $!; +command_ok( + [ + 'pg_ctl', '--wait', + '--pgdata' => $confdir, + '--log' => $node->logfile, + 'start' + ], + 'pg_ctl starts from a directory with only postgresql.json'); +is($node->safe_psql('postgres', 'SHOW data_directory'), + $datadir, 'data directory found through postgresql.json'); +command_ok([ 'pg_ctl', '--wait', '--pgdata' => $confdir, 'stop' ], + 'pg_ctl stops through the configuration directory'); + +# An invalid value in the root is fatal at startup, unlike at reload. +write_file($root, '{"include": ["cluster.conf"], "work_mem": "lots"}'); +$ret = $node->start(fail_ok => 1); +is($ret, 0, 'bad startup fails'); +ok($node->log_contains(qr/contains errors/), 'startup errors reported'); + +# The good root starts again; conf.d/30-c.conf still overrides it. +write_file($root, $json); +$node->start; +is($node->safe_psql('postgres', 'SHOW work_mem'), + '10MB', 'restored root works'); +$node->stop; + +done_testing(); diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 77e1a2810e5..b414b9928e0 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -2,6 +2,7 @@ subdir('brin') subdir('commit_ts') +subdir('config_json') subdir('delay_execution') subdir('dummy_index_am') subdir('dummy_seclabel') diff --git a/src/test/ssl/t/005_json_hosts.pl b/src/test/ssl/t/005_json_hosts.pl index f6fec468533..7f83997d22f 100644 --- a/src/test/ssl/t/005_json_hosts.pl +++ b/src/test/ssl/t/005_json_hosts.pl @@ -1,7 +1,8 @@ # Copyright (c) 2026, PostgreSQL Global Development Group -# Test the JSON format of the SNI hosts configuration in the file named -# by hosts_file, and the pg_hosts_file_entries view for both formats. +# Test the JSON format of the SNI hosts configuration, as a file of its +# own named by hosts_file and as a section of a JSON server configuration +# file, and the pg_hosts_file_entries view for both formats. use strict; use warnings FATAL => 'all'; @@ -86,6 +87,7 @@ sub session_query my $datadir = $node->data_dir; my $hosts_json = "$datadir/pg_hosts.json"; my $hba_json = "$datadir/pg_hba.json"; +my $root = "$datadir/postgresql.json"; sub write_file { @@ -377,4 +379,41 @@ $node->connect_ok( $res = view_query('SELECT count(*) FROM pg_hosts_file_entries'); is($res, '0', 'view is empty without a hosts section'); +############################################################################## +# Section of a JSON server configuration file +############################################################################## + +# The same entries as the "hosts" section of postgresql.json, which pulls +# in the postgresql.conf written so far, except for the file parameters +# it replaces. +$node->stop; +my $conf = PostgreSQL::Test::Utils::slurp_file("$datadir/postgresql.conf"); +$conf =~ s/^(hba_file|hosts_file) = .*\n//mg; +write_file("$datadir/postgresql.conf", $conf); +unlink $hosts_json, $hba_json; +chomp($hosts); +write_file( + $root, + qq({"include": ["postgresql.conf"],\n) + . qq( "hba": {"children": [{"import": "pg_hba.conf"}]},\n) + . qq( "hosts": $hosts}\n)); +$node->start(options => "-c config_file=$root"); + +$node->connect_ok( + "$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca", + 'per-host certificate selected from the configuration file', + sql => q{SELECT setting, source FROM pg_settings WHERE name = 'work_mem'}, + expected_stdout => qr/^11264\|hosts$/m); +$res = view_query( + q{ + SELECT entry_number, regexp_replace(file_name, '.*/', ''), hostname + FROM pg_hosts_file_entries ORDER BY entry_number, hostname}); +is( $res, + qq{1|hosts_extra.conf|example.com\n} + . qq{1|hosts_extra.conf|example.net\n} + . qq{2|postgresql.json|example.org\n} + . qq{3|postgresql.json|*\n} + . qq{4|postgresql.json|/no_sni/}, + 'pg_hosts_file_entries lists the entries of the configuration file'); + done_testing(); -- 2.56.0