Skip to content

Commit 807633b

Browse files
committed
pgAdmin Gunicorn hosting
This update updates the namespace scoped pgAdmin implementation to use Gunicorn for hosting. Server configuration is available via the PGAdmin manifest under spec.config.gunicorn. Issue: PGO-546
1 parent 928f960 commit 807633b

File tree

8 files changed

+319
-57
lines changed

8 files changed

+319
-57
lines changed

config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,10 @@ spec:
10821082
type: object
10831083
type: object
10841084
type: array
1085+
gunicorn:
1086+
description: 'Settings for the gunicorn server. More info: https://docs.gunicorn.org/en/latest/settings.html'
1087+
type: object
1088+
x-kubernetes-preserve-unknown-fields: true
10851089
ldapBindPassword:
10861090
description: 'A Secret containing the value for the LDAP_BIND_PASSWORD
10871091
setting. More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html'

internal/controller/standalone_pgadmin/config.go

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
// ConfigMap keys used also in mounting volume to pod
2020
settingsConfigMapKey = "pgadmin-settings.json"
2121
settingsClusterMapKey = "pgadmin-shared-clusters.json"
22+
gunicornConfigKey = "gunicorn-config.json"
2223

2324
// Port address used to define pod and service
2425
pgAdminPort = 5050

internal/controller/standalone_pgadmin/configmap.go

+39
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/json"
2121
"fmt"
2222
"sort"
23+
"strconv"
2324

2425
corev1 "k8s.io/api/core/v1"
2526

@@ -76,6 +77,11 @@ func configmap(pgadmin *v1beta1.PGAdmin,
7677
configmap.Data[settingsClusterMapKey] = clusterSettings
7778
}
7879

80+
gunicornSettings, err := generateGunicornConfig(pgadmin)
81+
if err == nil {
82+
configmap.Data[gunicornConfigKey] = gunicornSettings
83+
}
84+
7985
return configmap, err
8086
}
8187

@@ -181,3 +187,36 @@ func generateClusterConfig(
181187
err := encoder.Encode(servers)
182188
return buffer.String(), err
183189
}
190+
191+
// generateGunicornConfig generates the config settings for the gunicorn server
192+
// - https://docs.gunicorn.org/en/latest/settings.html
193+
func generateGunicornConfig(pgadmin *v1beta1.PGAdmin) (string, error) {
194+
settings := map[string]any{
195+
// Bind to all IPv4 addresses and set 25 threads by default.
196+
// - https://docs.gunicorn.org/en/latest/settings.html#bind
197+
// - https://docs.gunicorn.org/en/latest/settings.html#threads
198+
"bind": "0.0.0.0:" + strconv.Itoa(pgAdminPort),
199+
"threads": 25,
200+
}
201+
202+
// Copy any specified settings over the defaults.
203+
for k, v := range pgadmin.Spec.Config.Gunicorn {
204+
settings[k] = v
205+
}
206+
207+
// Write mandatory settings over any specified ones.
208+
// - https://docs.gunicorn.org/en/latest/settings.html#workers
209+
settings["workers"] = 1
210+
211+
// To avoid spurious reconciles, the following value must not change when
212+
// the spec does not change. [json.Encoder] and [json.Marshal] do this by
213+
// emitting map keys in sorted order. Indent so the value is not rendered
214+
// as one long line by `kubectl`.
215+
buffer := new(bytes.Buffer)
216+
encoder := json.NewEncoder(buffer)
217+
encoder.SetEscapeHTML(false)
218+
encoder.SetIndent("", " ")
219+
err := encoder.Encode(settings)
220+
221+
return buffer.String(), err
222+
}

internal/controller/standalone_pgadmin/configmap_test.go

+82
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,85 @@ namespace: some-ns
219219
})
220220
})
221221
}
222+
223+
func TestGenerateGunicornConfig(t *testing.T) {
224+
require.ParallelCapacity(t, 0)
225+
226+
t.Run("Default", func(t *testing.T) {
227+
pgAdmin := &v1beta1.PGAdmin{}
228+
pgAdmin.Name = "test"
229+
pgAdmin.Namespace = "postgres-operator"
230+
231+
expectedString := `{
232+
"bind": "0.0.0.0:5050",
233+
"threads": 25,
234+
"workers": 1
235+
}
236+
`
237+
actualString, err := generateGunicornConfig(pgAdmin)
238+
assert.NilError(t, err)
239+
assert.Equal(t, actualString, expectedString)
240+
})
241+
242+
t.Run("Add Settings", func(t *testing.T) {
243+
pgAdmin := &v1beta1.PGAdmin{}
244+
pgAdmin.Name = "test"
245+
pgAdmin.Namespace = "postgres-operator"
246+
pgAdmin.Spec.Config.Gunicorn = map[string]any{
247+
"keyfile": "/path/to/keyfile",
248+
"certfile": "/path/to/certfile",
249+
}
250+
251+
expectedString := `{
252+
"bind": "0.0.0.0:5050",
253+
"certfile": "/path/to/certfile",
254+
"keyfile": "/path/to/keyfile",
255+
"threads": 25,
256+
"workers": 1
257+
}
258+
`
259+
actualString, err := generateGunicornConfig(pgAdmin)
260+
assert.NilError(t, err)
261+
assert.Equal(t, actualString, expectedString)
262+
})
263+
264+
t.Run("Update Defaults", func(t *testing.T) {
265+
pgAdmin := &v1beta1.PGAdmin{}
266+
pgAdmin.Name = "test"
267+
pgAdmin.Namespace = "postgres-operator"
268+
pgAdmin.Spec.Config.Gunicorn = map[string]any{
269+
"bind": "127.0.0.1:5051",
270+
"threads": 30,
271+
}
272+
273+
expectedString := `{
274+
"bind": "127.0.0.1:5051",
275+
"threads": 30,
276+
"workers": 1
277+
}
278+
`
279+
actualString, err := generateGunicornConfig(pgAdmin)
280+
assert.NilError(t, err)
281+
assert.Equal(t, actualString, expectedString)
282+
})
283+
284+
t.Run("Update Mandatory", func(t *testing.T) {
285+
pgAdmin := &v1beta1.PGAdmin{}
286+
pgAdmin.Name = "test"
287+
pgAdmin.Namespace = "postgres-operator"
288+
pgAdmin.Spec.Config.Gunicorn = map[string]any{
289+
"workers": "100",
290+
}
291+
292+
expectedString := `{
293+
"bind": "0.0.0.0:5050",
294+
"threads": 25,
295+
"workers": 1
296+
}
297+
`
298+
actualString, err := generateGunicornConfig(pgAdmin)
299+
assert.NilError(t, err)
300+
assert.Equal(t, actualString, expectedString)
301+
})
302+
303+
}

internal/controller/standalone_pgadmin/pod.go

+66-27
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ import (
2828
)
2929

3030
const (
31-
configMountPath = "/etc/pgadmin/conf.d"
32-
configFilePath = "~postgres-operator/" + settingsConfigMapKey
33-
clusterFilePath = "~postgres-operator/" + settingsClusterMapKey
34-
ldapFilePath = "~postgres-operator/ldap-bind-password"
31+
configMountPath = "/etc/pgadmin/conf.d"
32+
configFilePath = "~postgres-operator/" + settingsConfigMapKey
33+
clusterFilePath = "~postgres-operator/" + settingsClusterMapKey
34+
ldapFilePath = "~postgres-operator/ldap-bind-password"
35+
gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey
3536

3637
// Nothing should be mounted to this location except the script our initContainer writes
3738
scriptMountPath = "/etc/pgadmin"
@@ -210,6 +211,10 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core
210211
Key: settingsClusterMapKey,
211212
Path: clusterFilePath,
212213
},
214+
{
215+
Key: gunicornConfigKey,
216+
Path: gunicornConfigFilePath,
217+
},
213218
},
214219
},
215220
},
@@ -262,32 +267,43 @@ func startupScript(pgadmin *v1beta1.PGAdmin) []string {
262267
var setupCommandV7 = "python3 ${PGADMIN_DIR}/setup.py"
263268
var setupCommandV8 = setupCommandV7 + " setup-db"
264269

270+
// startCommands (v8 image includes Gunicorn)
271+
var startCommandV7 = "pgadmin4 &"
272+
var startCommandV8 = "gunicorn -c /etc/pgadmin/gunicorn_config.py --chdir $PGADMIN_DIR pgAdmin4:app &"
273+
265274
// This script sets up, starts pgadmin, and runs the appropriate `loadServerCommand` to register the discovered servers.
275+
// pgAdmin is hosted by Gunicorn and uses a config file.
276+
// - https://www.pgadmin.org/docs/pgadmin4/development/server_deployment.html#standalone-gunicorn-configuration
277+
// - https://docs.gunicorn.org/en/latest/configure.html
266278
var startScript = fmt.Sprintf(`
267279
PGADMIN_DIR=/usr/local/lib/python3.11/site-packages/pgadmin4
268280
APP_RELEASE=$(cd $PGADMIN_DIR && python3 -c "import config; print(config.APP_RELEASE)")
269281
270282
echo "Running pgAdmin4 Setup"
271283
if [ $APP_RELEASE -eq 7 ]; then
272-
%s
284+
%s
273285
else
274-
%s
286+
%s
275287
fi
276288
277289
echo "Starting pgAdmin4"
278290
PGADMIN4_PIDFILE=/tmp/pgadmin4.pid
279-
pgadmin4 &
291+
if [ $APP_RELEASE -eq 7 ]; then
292+
%s
293+
else
294+
%s
295+
fi
280296
echo $! > $PGADMIN4_PIDFILE
281297
282298
loadServerCommand() {
283-
if [ $APP_RELEASE -eq 7 ]; then
284-
%s
285-
else
286-
%s
287-
fi
299+
if [ $APP_RELEASE -eq 7 ]; then
300+
%s
301+
else
302+
%s
303+
fi
288304
}
289305
loadServerCommand
290-
`, setupCommandV7, setupCommandV8, loadServerCommandV7, loadServerCommandV8)
306+
`, setupCommandV7, setupCommandV8, startCommandV7, startCommandV8, loadServerCommandV7, loadServerCommandV8)
291307

292308
// Use a Bash loop to periodically check:
293309
// 1. the mtime of the mounted configuration volume for shared/discovered servers.
@@ -303,17 +319,21 @@ loadServerCommand
303319
var reloadScript = `
304320
exec {fd}<> <(:)
305321
while read -r -t 5 -u "${fd}" || true; do
306-
if [ "${cluster_file}" -nt "/proc/self/fd/${fd}" ] && loadServerCommand
307-
then
308-
exec {fd}>&- && exec {fd}<> <(:)
309-
stat --format='Loaded shared servers dated %y' "${cluster_file}"
310-
fi
311-
if [ ! -d /proc/$(cat $PGADMIN4_PIDFILE) ]
312-
then
313-
pgadmin4 &
314-
echo $! > $PGADMIN4_PIDFILE
315-
echo "Restarting pgAdmin4"
316-
fi
322+
if [ "${cluster_file}" -nt "/proc/self/fd/${fd}" ] && loadServerCommand
323+
then
324+
exec {fd}>&- && exec {fd}<> <(:)
325+
stat --format='Loaded shared servers dated %y' "${cluster_file}"
326+
fi
327+
if [ ! -d /proc/$(cat $PGADMIN4_PIDFILE) ]
328+
then
329+
if [ $APP_RELEASE -eq 7 ]; then
330+
` + startCommandV7 + `
331+
else
332+
` + startCommandV8 + `
333+
fi
334+
echo $! > $PGADMIN4_PIDFILE
335+
echo "Restarting pgAdmin4"
336+
fi
317337
done
318338
`
319339

@@ -333,8 +353,8 @@ func startupCommand() []string {
333353
// and sets those variables globally. That way those values are available as pgAdmin
334354
// configurations when pgAdmin starts.
335355
//
336-
// Note: All pgAdmin settings are uppercase with underscores, so ignore any keys/names
337-
// that are not.
356+
// Note: All pgAdmin settings are uppercase alphanumeric with underscores, so ignore
357+
// any keys/names that are not.
338358
//
339359
// Note: set pgAdmin's LDAP_BIND_PASSWORD setting from the Secret last
340360
// in order to overwrite configuration of LDAP_BIND_PASSWORD via ConfigMap JSON.
@@ -352,17 +372,36 @@ with open('` + configMountPath + `/` + configFilePath + `') as _f:
352372
if os.path.isfile('` + ldapPasswordAbsolutePath + `'):
353373
with open('` + ldapPasswordAbsolutePath + `') as _f:
354374
LDAP_BIND_PASSWORD = _f.read()
375+
`
376+
// gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup
377+
// after all other config files.
378+
// - https://docs.gunicorn.org/en/latest/configure.html#configuration-file
379+
//
380+
// This command writes a script in `/etc/pgadmin/gunicorn_config.py` that reads
381+
// from the `gunicorn-config.json` file and sets those variables globally.
382+
// That way those values are available as settings when gunicorn starts.
383+
//
384+
// Note: All gunicorn settings are lowercase with underscores, so ignore
385+
// any keys/names that are not.
386+
gunicornConfig = `
387+
import json, re
388+
with open('` + configMountPath + `/` + gunicornConfigFilePath + `') as _f:
389+
_conf, _data = re.compile(r'[a-z_]+'), json.load(_f)
390+
if type(_data) is dict:
391+
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
355392
`
356393
)
357394

358-
args := []string{strings.TrimLeft(configSystem, "\n")}
395+
args := []string{strings.TrimLeft(configSystem, "\n"), strings.TrimLeft(gunicornConfig, "\n")}
359396

360397
script := strings.Join([]string{
361398
// Use the initContainer to create this path to avoid the error noted here:
362399
// - https://github.com/kubernetes/kubernetes/issues/121294
363400
`mkdir -p /etc/pgadmin/conf.d`,
364401
// Write the system configuration into a read-only file.
365402
`(umask a-w && echo "$1" > ` + scriptMountPath + `/config_system.py` + `)`,
403+
// Write the server configuration into a read-only file.
404+
`(umask a-w && echo "$2" > ` + scriptMountPath + `/gunicorn_config.py` + `)`,
366405
}, "\n")
367406

368407
return append([]string{"bash", "-ceu", "--", script, "startup"}, args...)

0 commit comments

Comments
 (0)