diff --git a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml index cf290b0ec..8f8c4dcad 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml @@ -1320,7 +1320,7 @@ spec: type: array gunicorn: description: |- - Settings for the gunicorn server. + Settings for the Gunicorn server. More info: https://docs.gunicorn.org/en/latest/settings.html type: object x-kubernetes-preserve-unknown-fields: true @@ -1353,12 +1353,61 @@ spec: - name type: object x-kubernetes-map-type: atomic + oauthConfigurations: + description: |- + Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values + in the settings field, they will be combined with the values loaded here. + More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html + items: + properties: + name: + description: The OAUTH2_NAME of this configuration. + maxLength: 20 + minLength: 1 + pattern: ^[A-Za-z0-9]+$ + type: string + secret: + description: A Secret containing the settings of one OAuth2 + provider as a JSON object. + properties: + key: + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - secret + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map settings: description: |- Settings for the pgAdmin server process. Keys should be uppercase and values must be constants. More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html type: object + x-kubernetes-map-type: granular x-kubernetes-preserve-unknown-fields: true type: object dataVolumeClaimSpec: diff --git a/internal/controller/standalone_pgadmin/pod.go b/internal/controller/standalone_pgadmin/pod.go index ab6f8679f..88f483c57 100644 --- a/internal/controller/standalone_pgadmin/pod.go +++ b/internal/controller/standalone_pgadmin/pod.go @@ -28,6 +28,8 @@ const ( configDatabaseURIPath = "~postgres-operator/config-database-uri" ldapFilePath = "~postgres-operator/ldap-bind-password" gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey + oauthConfigDir = "~postgres-operator/oauth-config" + oauthAbsolutePath = configMountPath + "/" + oauthConfigDir // scriptMountPath is where to mount a temporary directory that is only // writable during Pod initialization. @@ -212,6 +214,17 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core }, }...) + for i, oauth := range pgadmin.Spec.Config.OAuthConfigurations { + // Safely encode the OAUTH2_NAME in the file name. Prepend the index so + // the files can be loaded in the order they are defined in the spec. + mountPath := fmt.Sprintf( + "%s/%02d-%s.json", oauthConfigDir, i, shell.CleanFileName(oauth.Name), + ) + config = append(config, corev1.VolumeProjection{ + Secret: initialize.Pointer(oauth.Secret.AsProjection(mountPath)), + }) + } + if pgadmin.Spec.Config.ConfigDatabaseURI != nil { config = append(config, corev1.VolumeProjection{ Secret: initialize.Pointer( @@ -311,15 +324,17 @@ loadServerCommand // descriptor and uses the timeout of the builtin `read` to wait. That same // descriptor gets closed and reopened to use the builtin `[ -nt` to check mtimes. // - https://unix.stackexchange.com/a/407383 - // In order to get gunicorn to reload the logging config - // we need to send a KILL rather than a HUP signal. + // + // Gunicorn needs a SIGTERM rather than SIGHUP to reload its logging config. + // This also causes pgAdmin to restart when its configuration changes. // - https://github.com/benoitc/gunicorn/issues/3353 + // // Right now the config file is on the same configMap as the cluster file // so if the mtime changes for any of those files, it will change for all. var reloadScript = ` exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?}); + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -375,12 +390,31 @@ with open('` + configMountPath + `/` + configFilePath + `') as _f: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) +if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] +for _f in reversed(glob.glob('` + oauthAbsolutePath + `/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('` + ldapPasswordAbsolutePath + `'): with open('` + ldapPasswordAbsolutePath + `') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('` + configDatabaseURIPathAbsolutePath + `'): with open('` + configDatabaseURIPathAbsolutePath + `') as _f: CONFIG_DATABASE_URI = _f.read() +del _conf, _data, _f ` // Gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup diff --git a/internal/controller/standalone_pgadmin/pod_test.go b/internal/controller/standalone_pgadmin/pod_test.go index b414a7bab..c0aee8ca0 100644 --- a/internal/controller/standalone_pgadmin/pod_test.go +++ b/internal/controller/standalone_pgadmin/pod_test.go @@ -74,7 +74,7 @@ containers: exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?}); + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -148,12 +148,31 @@ initContainers: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) + if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] + for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'): with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'): with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f: CONFIG_DATABASE_URI = _f.read() + del _conf, _data, _f - | import json, re, gunicorn gunicorn.SERVER_SOFTWARE = 'Python' @@ -260,7 +279,7 @@ containers: exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?}); + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -338,12 +357,31 @@ initContainers: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) + if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] + for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'): with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'): with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f: CONFIG_DATABASE_URI = _f.read() + del _conf, _data, _f - | import json, re, gunicorn gunicorn.SERVER_SOFTWARE = 'Python' diff --git a/internal/shell/paths.go b/internal/shell/paths.go index 3455ff8fe..d1df635e6 100644 --- a/internal/shell/paths.go +++ b/internal/shell/paths.go @@ -14,6 +14,23 @@ import ( "strings" ) +// CleanFileName returns the suffix of path after its last slash U+002F. +// This is similar to "basename" except this returns empty string when: +// - The final character of path is slash U+002F, or +// - The result would be "." or ".." +// +// See: +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/basename.html +func CleanFileName(path string) string { + if i := strings.LastIndexByte(path, '/'); i >= 0 { + path = path[i+1:] + } + if path != "." && path != ".." { + return path + } + return "" +} + // MakeDirectories returns a list of POSIX shell commands that ensure each path // exists. It creates every directory leading to path from (but not including) // base and sets their permissions to exactly perms, regardless of umask. diff --git a/internal/shell/paths_test.go b/internal/shell/paths_test.go index 273f672b7..8af16a73c 100644 --- a/internal/shell/paths_test.go +++ b/internal/shell/paths_test.go @@ -17,6 +17,36 @@ import ( "github.com/crunchydata/postgres-operator/internal/testing/require" ) +func TestCleanFileName(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, CleanFileName(""), "") + }) + + t.Run("Dots", func(t *testing.T) { + assert.Equal(t, CleanFileName("."), "") + assert.Equal(t, CleanFileName(".."), "") + assert.Equal(t, CleanFileName("..."), "...") + assert.Equal(t, CleanFileName("././/.././../."), "") + assert.Equal(t, CleanFileName("././/.././../.."), "") + assert.Equal(t, CleanFileName("././/.././../../x.j"), "x.j") + }) + + t.Run("Directories", func(t *testing.T) { + assert.Equal(t, CleanFileName("/"), "") + assert.Equal(t, CleanFileName("//"), "") + assert.Equal(t, CleanFileName("asdf/"), "") + assert.Equal(t, CleanFileName("asdf//12.3"), "12.3") + assert.Equal(t, CleanFileName("//////"), "") + assert.Equal(t, CleanFileName("//////gg"), "gg") + }) + + t.Run("NoSeparators", func(t *testing.T) { + assert.Equal(t, CleanFileName("asdf12.3.ssgg"), "asdf12.3.ssgg") + }) +} + func TestMakeDirectories(t *testing.T) { t.Parallel() diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go index 9042245b2..534d792c4 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go @@ -21,7 +21,7 @@ type StandalonePGAdminConfiguration struct { // +optional ConfigDatabaseURI *OptionalSecretKeyRef `json:"configDatabaseURI,omitempty"` - // Settings for the gunicorn server. + // Settings for the Gunicorn server. // More info: https://docs.gunicorn.org/en/latest/settings.html // +optional // +kubebuilder:pruning:PreserveUnknownFields @@ -37,11 +37,46 @@ type StandalonePGAdminConfiguration struct { // Settings for the pgAdmin server process. Keys should be uppercase and // values must be constants. // More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html - // +optional + // --- // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Schemaless // +kubebuilder:validation:Type=object + // + // +mapType=granular + // +optional Settings SchemalessObject `json:"settings,omitempty"` + + // Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values + // in the settings field, they will be combined with the values loaded here. + // More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html + // --- + // The controller expects this number to be no more than two digits. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + // + // +listType=map + // +listMapKey=name + // +optional + OAuthConfigurations []PGAdminOAuthConfig `json:"oauthConfigurations,omitempty"` +} + +// +structType=atomic +type PGAdminOAuthConfig struct { + // The OAUTH2_NAME of this configuration. + // --- + // This goes into a filename, so let's keep it short and simple. + // The Secret is allowed to contain OAUTH2_NAME and deviate from this. + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9]+$` + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=20 + // +required + Name string `json:"name"` + + // A Secret containing the settings of one OAuth2 provider as a JSON object. + // --- + // +required + Secret SecretKeyRef `json:"secret"` } // PGAdminSpec defines the desired state of PGAdmin diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go index b13939034..ff118a9e4 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go @@ -737,6 +737,22 @@ func (in *PGAdminList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PGAdminOAuthConfig) DeepCopyInto(out *PGAdminOAuthConfig) { + *out = *in + in.Secret.DeepCopyInto(&out.Secret) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGAdminOAuthConfig. +func (in *PGAdminOAuthConfig) DeepCopy() *PGAdminOAuthConfig { + if in == nil { + return nil + } + out := new(PGAdminOAuthConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGAdminPodSpec) DeepCopyInto(out *PGAdminPodSpec) { *out = *in @@ -2588,6 +2604,13 @@ func (in *StandalonePGAdminConfiguration) DeepCopyInto(out *StandalonePGAdminCon (*in).DeepCopyInto(*out) } out.Settings = in.Settings.DeepCopy() + if in.OAuthConfigurations != nil { + in, out := &in.OAuthConfigurations, &out.OAuthConfigurations + *out = make([]PGAdminOAuthConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StandalonePGAdminConfiguration.