From 31adf43d4974c0783a0df5d3bbda830d383ffc76 Mon Sep 17 00:00:00 2001 From: TJ Moore Date: Thu, 28 Sep 2023 16:56:58 -0400 Subject: [PATCH 1/2] Reconcile a pgAdmin StatefulSet, Pod PVC and ConfigMap Add the reconciliation logic for the main initial elements for pgAdmin. Includes initial configuration options for the StatefulSet and example implementations for the ConfigMap, PVC and Status block --- build/crd/pgadmins/todos.yaml | 9 + cmd/postgres-operator/main.go | 8 +- ...res-operator.crunchydata.com_pgadmins.yaml | 527 +++++++++++ docs/content/references/crd.md | 873 +++++++++++++++++- examples/pgadmin/pgadmin.yaml | 10 +- .../controller/standalone_pgadmin/apply.go | 57 ++ .../controller/standalone_pgadmin/config.go | 20 + .../standalone_pgadmin/configmap.go | 65 ++ .../standalone_pgadmin/configmap_test.go | 76 ++ .../standalone_pgadmin/controller.go | 74 +- .../standalone_pgadmin/helpers_test.go | 147 +++ internal/controller/standalone_pgadmin/pod.go | 131 +++ .../controller/standalone_pgadmin/pod_test.go | 172 ++++ .../standalone_pgadmin/statefulset.go | 112 +++ .../standalone_pgadmin/statefulset_test.go | 216 +++++ .../controller/standalone_pgadmin/volume.go | 150 +++ .../standalone_pgadmin/volume_test.go | 307 ++++++ internal/naming/controllers.go | 3 +- internal/naming/labels.go | 12 + internal/naming/labels_test.go | 3 + internal/naming/names.go | 32 + .../v1beta1/standalone_pgadmin_types.go | 52 +- .../v1beta1/zz_generated.deepcopy.go | 44 +- 23 files changed, 3070 insertions(+), 30 deletions(-) create mode 100644 internal/controller/standalone_pgadmin/apply.go create mode 100644 internal/controller/standalone_pgadmin/config.go create mode 100644 internal/controller/standalone_pgadmin/configmap.go create mode 100644 internal/controller/standalone_pgadmin/configmap_test.go create mode 100644 internal/controller/standalone_pgadmin/helpers_test.go create mode 100644 internal/controller/standalone_pgadmin/pod.go create mode 100644 internal/controller/standalone_pgadmin/pod_test.go create mode 100644 internal/controller/standalone_pgadmin/statefulset.go create mode 100644 internal/controller/standalone_pgadmin/statefulset_test.go create mode 100644 internal/controller/standalone_pgadmin/volume.go create mode 100644 internal/controller/standalone_pgadmin/volume_test.go diff --git a/build/crd/pgadmins/todos.yaml b/build/crd/pgadmins/todos.yaml index c0d2202859..285c688088 100644 --- a/build/crd/pgadmins/todos.yaml +++ b/build/crd/pgadmins/todos.yaml @@ -4,5 +4,14 @@ - op: copy from: /work path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/imagePullSecrets/items/properties/name/description +- op: copy + from: /work + path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/config/properties/files/items/properties/configMap/properties/name/description +- op: copy + from: /work + path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/config/properties/files/items/properties/secret/properties/name/description +- op: copy + from: /work + path: /spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/config/properties/ldapBindPassword/properties/name/description - op: remove path: /work diff --git a/cmd/postgres-operator/main.go b/cmd/postgres-operator/main.go index c4fc9fed08..f3496d4f55 100644 --- a/cmd/postgres-operator/main.go +++ b/cmd/postgres-operator/main.go @@ -33,6 +33,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/controller/standalone_pgadmin" "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/upgradecheck" "github.com/crunchydata/postgres-operator/internal/util" ) @@ -153,9 +154,10 @@ func addControllersToManager(mgr manager.Manager, openshift bool, log logr.Logge if util.DefaultMutableFeatureGate.Enabled(util.StandalonePGAdmin) { pgAdminReconciler := &standalone_pgadmin.PGAdminReconciler{ - Client: mgr.GetClient(), - Owner: "pgadmin-controller", - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Owner: "pgadmin-controller", + Recorder: mgr.GetEventRecorderFor(naming.ControllerPGAdmin), + Scheme: mgr.GetScheme(), } if err := pgAdminReconciler.SetupWithManager(mgr); err != nil { diff --git a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml index 3e12610308..97c0a23def 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml @@ -855,6 +855,419 @@ spec: type: array type: object type: object + config: + description: Configuration settings for the pgAdmin process. Changes + to any of these values will be loaded without validation. Be careful, + as you may put pgAdmin into an unusable state. + properties: + files: + description: Files allows the user to mount projected volumes + into the pgAdmin container so that files can be referenced by + pgAdmin as needed. + items: + description: Projection that may be projected along with other + supported volume types + properties: + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: items if unspecified, each key-value pair + in the Data field of the referenced ConfigMap will + be projected into the volume as a file whose name + is the key and content is the value. If specified, + the listed keys will be projected into the specified + paths, and unlisted keys will not be present. If a + key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. + Paths must be relative and may not contain the '..' + path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file. Must be an + octal value between 0000 and 0777 or a decimal + value between 0 and 511. YAML accepts both octal + and decimal values, JSON requires decimal values + for mode bits. If not specified, the volume + defaultMode will be used. This might be in conflict + with other options that affect the file mode, + like fsGroup, and the result can be other mode + bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the + file to map the key to. May not be an absolute + path. May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name and namespace + are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + mode: + description: 'Optional: mode bits used to set + permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between + 0 and 511. YAML accepts both octal and decimal + values, JSON requires decimal values for mode + bits. If not specified, the volume defaultMode + will be used. This might be in conflict with + other options that affect the file mode, like + fsGroup, and the result can be other mode bits + set.' + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: 'Selects a resource of the container: + only resources limits and requests (limits.cpu, + limits.memory, requests.cpu and requests.memory) + are currently supported.' + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: items if unspecified, each key-value pair + in the Data field of the referenced Secret will be + projected into the volume as a file whose name is + the key and content is the value. If specified, the + listed keys will be projected into the specified paths, + and unlisted keys will not be present. If a key is + specified which is not present in the Secret, the + volume setup will error unless it is marked optional. + Paths must be relative and may not contain the '..' + path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file. Must be an + octal value between 0000 and 0777 or a decimal + value between 0 and 511. YAML accepts both octal + and decimal values, JSON requires decimal values + for mode bits. If not specified, the volume + defaultMode will be used. This might be in conflict + with other options that affect the file mode, + like fsGroup, and the result can be other mode + bits set.' + format: int32 + type: integer + path: + description: path is the relative path of the + file to map the key to. May not be an absolute + path. May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: audience is the intended audience of the + token. A recipient of a token must identify itself + with an identifier specified in the audience of the + token, and otherwise should reject the token. The + audience defaults to the identifier of the apiserver. + type: string + expirationSeconds: + description: expirationSeconds is the requested duration + of validity of the service account token. As the token + approaches expiration, the kubelet volume plugin will + proactively rotate the service account token. The + kubelet will start trying to rotate the token if the + token is older than 80 percent of its time to live + or if the token is older than 24 hours.Defaults to + 1 hour and must be at least 10 minutes. + format: int64 + type: integer + path: + description: path is the path relative to the mount + point of the file to project the token into. + type: string + required: + - path + type: object + type: object + type: array + ldapBindPassword: + description: 'A Secret containing the value for the LDAP_BIND_PASSWORD + setting. More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html' + properties: + key: + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + optional: + description: Specify whether the Secret or its key must be + defined + type: boolean + required: + - key + type: object + 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-preserve-unknown-fields: true + type: object + dataVolumeClaimSpec: + description: 'Defines a PersistentVolumeClaim for pgAdmin data. More + info: https://kubernetes.io/docs/concepts/storage/persistent-volumes' + properties: + accessModes: + description: 'accessModes contains the desired access modes the + volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' + items: + type: string + type: array + dataSource: + description: 'dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) If the provisioner + or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified + data source. If the AnyVolumeDataSource feature gate is enabled, + this field will always have the same contents as the DataSourceRef + field.' + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + dataSourceRef: + description: 'dataSourceRef specifies the object from which to + populate the volume with data, if a non-empty volume is desired. + This may be any local object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. When this field + is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator + or dynamic provisioner. This field will replace the functionality + of the DataSource field and as such if both fields are non-empty, + they must have the same value. For backwards compatibility, + both fields (DataSource and DataSourceRef) will be set to the + same value automatically if one of them is empty and the other + is non-empty. There are two important differences between DataSource + and DataSourceRef: * While DataSource only allows two specific + types of objects, DataSourceRef allows any non-core object, + as well as PersistentVolumeClaim objects. * While DataSource + ignores disallowed values (dropping them), DataSourceRef preserves + all values, and generates an error if a disallowed value is + specified. (Beta) Using this field requires the AnyVolumeDataSource + feature gate to be enabled.' + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + resources: + description: 'resources represents the minimum resources the volume + should have. If RecoverVolumeExpansionFailure feature is enabled + users are allowed to specify resource requirements that are + lower than previous value but must still be higher than capacity + recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: 'Limits describes the maximum amount of compute + resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: 'Requests describes the minimum amount of compute + resources required. If Requests is omitted for a container, + it defaults to Limits if that is explicitly specified, otherwise + to an implementation-defined value. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' + type: object + type: object + selector: + description: selector is a label query over volumes to consider + for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector + that contains values, a key, and an operator that relates + the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: operator represents a key's relationship + to a set of values. Valid operators are In, NotIn, + Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If + the operator is In or NotIn, the values array must + be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced + during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A + single {key,value} in the matchLabels map is equivalent + to an element of matchExpressions, whose key field is "key", + the operator is "In", and the values array contains only + "value". The requirements are ANDed. + type: object + type: object + storageClassName: + description: 'storageClassName is the name of the StorageClass + required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' + type: string + volumeMode: + description: volumeMode defines what type of volume is required + by the claim. Value of Filesystem is implied when not included + in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the PersistentVolume + backing this claim. + type: string + type: object image: description: The image name to use for standalone pgAdmin instance. type: string @@ -921,6 +1334,37 @@ spec: to an implementation-defined value. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' type: object type: object + service: + description: Specification of the service that exposes pgAdmin. + properties: + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + nodePort: + description: The port on which this service is exposed when type + is NodePort or LoadBalancer. Value must be in-range and not + in use or the operation will fail. If unspecified, a port will + be allocated if this Service requires one. - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + format: int32 + type: integer + type: + default: ClusterIP + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types' + enum: + - ClusterIP + - NodePort + - LoadBalancer + type: string + type: object tolerations: description: 'Tolerations of the PGAdmin pod. More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration' items: @@ -961,9 +1405,92 @@ spec: type: string type: object type: array + required: + - dataVolumeClaimSpec type: object status: description: PGAdminStatus defines the observed state of PGAdmin + properties: + conditions: + description: 'conditions represent the observations of pgadmin''s + current state. Known .status.conditions.type are: "PersistentVolumeResizing", + "Progressing", "ProxyAvailable"' + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + type FooStatus struct{ // Represents the observations of a foo's + current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: observedGeneration represents the .metadata.generation + on which the status was based. + format: int64 + minimum: 0 + type: integer type: object type: object served: true diff --git a/docs/content/references/crd.md b/docs/content/references/crd.md index 9302a3519f..f27f2027b9 100644 --- a/docs/content/references/crd.md +++ b/docs/content/references/crd.md @@ -62,7 +62,7 @@ PGAdmin is the Schema for the pgadmins API PGAdminSpec defines the desired state of PGAdmin false - status + status object PGAdminStatus defines the observed state of PGAdmin false @@ -89,10 +89,20 @@ PGAdminSpec defines the desired state of PGAdmin + dataVolumeClaimSpec + object + Defines a PersistentVolumeClaim for pgAdmin data. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + true + affinity object Scheduling constraints of the PGAdmin pod. More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node false + + config + object + Configuration settings for the pgAdmin process. Changes to any of these values will be loaded without validation. Be careful, as you may put pgAdmin into an unusable state. + false image string @@ -123,6 +133,11 @@ PGAdminSpec defines the desired state of PGAdmin object Resource requirements for the PGAdmin container. false + + service + object + Specification of the service that exposes pgAdmin. + false tolerations []object @@ -132,6 +147,243 @@ PGAdminSpec defines the desired state of PGAdmin +

+ PGAdmin.spec.dataVolumeClaimSpec + ↩ Parent +

+ + + +Defines a PersistentVolumeClaim for pgAdmin data. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessModes[]stringaccessModes contains the desired access modes the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1false
dataSourceobjectdataSource field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) * An existing PVC (PersistentVolumeClaim) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. If the AnyVolumeDataSource feature gate is enabled, this field will always have the same contents as the DataSourceRef field.false
dataSourceRefobjectdataSourceRef specifies the object from which to populate the volume with data, if a non-empty volume is desired. This may be any local object from a non-empty API group (non core object) or a PersistentVolumeClaim object. When this field is specified, volume binding will only succeed if the type of the specified object matches some installed volume populator or dynamic provisioner. This field will replace the functionality of the DataSource field and as such if both fields are non-empty, they must have the same value. For backwards compatibility, both fields (DataSource and DataSourceRef) will be set to the same value automatically if one of them is empty and the other is non-empty. There are two important differences between DataSource and DataSourceRef: * While DataSource only allows two specific types of objects, DataSourceRef allows any non-core object, as well as PersistentVolumeClaim objects. * While DataSource ignores disallowed values (dropping them), DataSourceRef preserves all values, and generates an error if a disallowed value is specified. (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled.false
resourcesobjectresources represents the minimum resources the volume should have. If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resourcesfalse
selectorobjectselector is a label query over volumes to consider for binding.false
storageClassNamestringstorageClassName is the name of the StorageClass required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1false
volumeModestringvolumeMode defines what type of volume is required by the claim. Value of Filesystem is implied when not included in claim spec.false
volumeNamestringvolumeName is the binding reference to the PersistentVolume backing this claim.false
+ + +

+ PGAdmin.spec.dataVolumeClaimSpec.dataSource + ↩ Parent +

+ + + +dataSource field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) * An existing PVC (PersistentVolumeClaim) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. If the AnyVolumeDataSource feature gate is enabled, this field will always have the same contents as the DataSourceRef field. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
kindstringKind is the type of resource being referencedtrue
namestringName is the name of resource being referencedtrue
apiGroupstringAPIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.false
+ + +

+ PGAdmin.spec.dataVolumeClaimSpec.dataSourceRef + ↩ Parent +

+ + + +dataSourceRef specifies the object from which to populate the volume with data, if a non-empty volume is desired. This may be any local object from a non-empty API group (non core object) or a PersistentVolumeClaim object. When this field is specified, volume binding will only succeed if the type of the specified object matches some installed volume populator or dynamic provisioner. This field will replace the functionality of the DataSource field and as such if both fields are non-empty, they must have the same value. For backwards compatibility, both fields (DataSource and DataSourceRef) will be set to the same value automatically if one of them is empty and the other is non-empty. There are two important differences between DataSource and DataSourceRef: * While DataSource only allows two specific types of objects, DataSourceRef allows any non-core object, as well as PersistentVolumeClaim objects. * While DataSource ignores disallowed values (dropping them), DataSourceRef preserves all values, and generates an error if a disallowed value is specified. (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
kindstringKind is the type of resource being referencedtrue
namestringName is the name of resource being referencedtrue
apiGroupstringAPIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.false
+ + +

+ PGAdmin.spec.dataVolumeClaimSpec.resources + ↩ Parent +

+ + + +resources represents the minimum resources the volume should have. If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements that are lower than previous value but must still be higher than capacity recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
limitsmap[string]int or stringLimits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/false
requestsmap[string]int or stringRequests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/false
+ + +

+ PGAdmin.spec.dataVolumeClaimSpec.selector + ↩ Parent +

+ + + +selector is a label query over volumes to consider for binding. + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
matchExpressions[]objectmatchExpressions is a list of label selector requirements. The requirements are ANDed.false
matchLabelsmap[string]stringmatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.false
+ + +

+ PGAdmin.spec.dataVolumeClaimSpec.selector.matchExpressions[index] + ↩ Parent +

+ + + +A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystringkey is the label key that the selector applies to.true
operatorstringoperator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.true
values[]stringvalues is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.false
+ +

PGAdmin.spec.affinity ↩ Parent @@ -1320,14 +1572,14 @@ A label selector requirement is a selector that contains values, a key, and an o -

- PGAdmin.spec.imagePullSecrets[index] +

+ PGAdmin.spec.config ↩ Parent

-LocalObjectReference contains enough information to let you locate the referenced object inside the same namespace. +Configuration settings for the pgAdmin process. Changes to any of these values will be loaded without validation. Be careful, as you may put pgAdmin into an unusable state. @@ -1339,22 +1591,32 @@ LocalObjectReference contains enough information to let you locate the reference - - - + + + + + + + + + + + + +
namestringName of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#namesfiles[]objectFiles allows the user to mount projected volumes into the pgAdmin container so that files can be referenced by pgAdmin as needed.false
ldapBindPasswordobjectA Secret containing the value for the LDAP_BIND_PASSWORD setting. More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.htmlfalse
settingsobjectSettings 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 false
-

- PGAdmin.spec.metadata - ↩ Parent +

+ PGAdmin.spec.config.files[index] + ↩ Parent

-Metadata contains metadata for custom resources +Projection that may be projected along with other supported volume types @@ -1366,9 +1628,438 @@ Metadata contains metadata for custom resources - - - + + + + + + + + + + + + + + + + + + + + +
annotationsmap[string]stringconfigMapobjectconfigMap information about the configMap data to projectfalse
downwardAPIobjectdownwardAPI information about the downwardAPI data to projectfalse
secretobjectsecret information about the secret data to projectfalse
serviceAccountTokenobjectserviceAccountToken is information about the serviceAccountToken data to projectfalse
+ + +

+ PGAdmin.spec.config.files[index].configMap + ↩ Parent +

+ + + +configMap information about the configMap data to project + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
items[]objectitems if unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'.false
namestringName of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#namesfalse
optionalbooleanoptional specify whether the ConfigMap or its keys must be definedfalse
+ + +

+ PGAdmin.spec.config.files[index].configMap.items[index] + ↩ Parent +

+ + + +Maps a string key to a path within a volume. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystringkey is the key to project.true
pathstringpath is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'.true
modeintegermode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.false
+ + +

+ PGAdmin.spec.config.files[index].downwardAPI + ↩ Parent +

+ + + +downwardAPI information about the downwardAPI data to project + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
items[]objectItems is a list of DownwardAPIVolume filefalse
+ + +

+ PGAdmin.spec.config.files[index].downwardAPI.items[index] + ↩ Parent +

+ + + +DownwardAPIVolumeFile represents information to create the file containing the pod field + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstringRequired: Path is the relative path name of the file to be created. Must not be absolute or contain the '..' path. Must be utf-8 encoded. The first item of the relative path must not start with '..'true
fieldRefobjectRequired: Selects a field of the pod: only annotations, labels, name and namespace are supported.false
modeintegerOptional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.false
resourceFieldRefobjectSelects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.false
+ + +

+ PGAdmin.spec.config.files[index].downwardAPI.items[index].fieldRef + ↩ Parent +

+ + + +Required: Selects a field of the pod: only annotations, labels, name and namespace are supported. + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
fieldPathstringPath of the field to select in the specified API version.true
apiVersionstringVersion of the schema the FieldPath is written in terms of, defaults to "v1".false
+ + +

+ PGAdmin.spec.config.files[index].downwardAPI.items[index].resourceFieldRef + ↩ Parent +

+ + + +Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
resourcestringRequired: resource to selecttrue
containerNamestringContainer name: required for volumes, optional for env varsfalse
divisorint or stringSpecifies the output format of the exposed resources, defaults to "1"false
+ + +

+ PGAdmin.spec.config.files[index].secret + ↩ Parent +

+ + + +secret information about the secret data to project + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
items[]objectitems if unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'.false
namestringName of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#namesfalse
optionalbooleanoptional field specify whether the Secret or its key must be definedfalse
+ + +

+ PGAdmin.spec.config.files[index].secret.items[index] + ↩ Parent +

+ + + +Maps a string key to a path within a volume. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystringkey is the key to project.true
pathstringpath is the relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'.true
modeintegermode is Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.false
+ + +

+ PGAdmin.spec.config.files[index].serviceAccountToken + ↩ Parent +

+ + + +serviceAccountToken is information about the serviceAccountToken data to project + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstringpath is the path relative to the mount point of the file to project the token into.true
audiencestringaudience is the intended audience of the token. A recipient of a token must identify itself with an identifier specified in the audience of the token, and otherwise should reject the token. The audience defaults to the identifier of the apiserver.false
expirationSecondsintegerexpirationSeconds is the requested duration of validity of the service account token. As the token approaches expiration, the kubelet volume plugin will proactively rotate the service account token. The kubelet will start trying to rotate the token if the token is older than 80 percent of its time to live or if the token is older than 24 hours.Defaults to 1 hour and must be at least 10 minutes.false
+ + +

+ PGAdmin.spec.config.ldapBindPassword + ↩ Parent +

+ + + +A Secret containing the value for the LDAP_BIND_PASSWORD setting. More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystringThe key of the secret to select from. Must be a valid secret key.true
namestringName of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#namesfalse
optionalbooleanSpecify whether the Secret or its key must be definedfalse
+ + +

+ PGAdmin.spec.imagePullSecrets[index] + ↩ Parent +

+ + + +LocalObjectReference contains enough information to let you locate the referenced object inside the same namespace. + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestringName of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#namesfalse
+ + +

+ PGAdmin.spec.metadata + ↩ Parent +

+ + + +Metadata contains metadata for custom resources + + + + + + + + + + + + + + @@ -1411,6 +2102,75 @@ Resource requirements for the PGAdmin container.
NameTypeDescriptionRequired
annotationsmap[string]string false
labels
+

+ PGAdmin.spec.service + ↩ Parent +

+ + + +Specification of the service that exposes pgAdmin. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
metadataobjectMetadata contains metadata for custom resourcesfalse
nodePortintegerThe port on which this service is exposed when type is NodePort or LoadBalancer. Value must be in-range and not in use or the operation will fail. If unspecified, a port will be allocated if this Service requires one. - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeportfalse
typeenumMore info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-typesfalse
+ + +

+ PGAdmin.spec.service.metadata + ↩ Parent +

+ + + +Metadata contains metadata for custom resources + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
annotationsmap[string]stringfalse
labelsmap[string]stringfalse
+ +

PGAdmin.spec.tolerations[index] ↩ Parent @@ -1457,6 +2217,91 @@ The pod this Toleration is attached to tolerates any taint that matches the trip + +

+ PGAdmin.status + ↩ Parent +

+ + + +PGAdminStatus defines the observed state of PGAdmin + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]objectconditions represent the observations of pgadmin's current state. Known .status.conditions.type are: "PersistentVolumeResizing", "Progressing", "ProxyAvailable"false
observedGenerationintegerobservedGeneration represents the .metadata.generation on which the status was based.false
+ + +

+ PGAdmin.status.conditions[index] + ↩ Parent +

+ + + +Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: "Available", "Progressing", and "Degraded" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + // other fields } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestringlastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.true
messagestringmessage is a human readable message indicating details about the transition. This may be an empty string.true
reasonstringreason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.true
statusenumstatus of the condition, one of True, False, Unknown.true
typestringtype of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)true
observedGenerationintegerobservedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.false
+

PGUpgrade

diff --git a/examples/pgadmin/pgadmin.yaml b/examples/pgadmin/pgadmin.yaml index afeabc570e..4405d9eaeb 100644 --- a/examples/pgadmin/pgadmin.yaml +++ b/examples/pgadmin/pgadmin.yaml @@ -1,5 +1,11 @@ apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PGAdmin metadata: - name: pgadmin -spec: {} + name: rhino +spec: + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi diff --git a/internal/controller/standalone_pgadmin/apply.go b/internal/controller/standalone_pgadmin/apply.go new file mode 100644 index 0000000000..af29aec74b --- /dev/null +++ b/internal/controller/standalone_pgadmin/apply.go @@ -0,0 +1,57 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// patch sends patch to object's endpoint in the Kubernetes API and updates +// object with any returned content. The fieldManager is set to r.Owner, but +// can be overridden in options. +// - https://docs.k8s.io/reference/using-api/server-side-apply/#managers +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func (r *PGAdminReconciler) patch( + ctx context.Context, object client.Object, + patch client.Patch, options ...client.PatchOption, +) error { + options = append([]client.PatchOption{r.Owner}, options...) + return r.Client.Patch(ctx, object, patch, options...) +} + +// apply sends an apply patch to object's endpoint in the Kubernetes API and +// updates object with any returned content. The fieldManager is set to +// r.Owner and the force parameter is true. +// - https://docs.k8s.io/reference/using-api/server-side-apply/#managers +// - https://docs.k8s.io/reference/using-api/server-side-apply/#conflicts +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func (r *PGAdminReconciler) apply(ctx context.Context, object client.Object) error { + // Generate an apply-patch by comparing the object to its zero value. + zero := reflect.New(reflect.TypeOf(object).Elem()).Interface() + data, err := client.MergeFrom(zero.(client.Object)).Data(object) + apply := client.RawPatch(client.Apply.Type(), data) + + // Send the apply-patch with force=true. + if err == nil { + err = r.patch(ctx, object, apply, client.ForceOwnership) + } + + return err +} diff --git a/internal/controller/standalone_pgadmin/config.go b/internal/controller/standalone_pgadmin/config.go new file mode 100644 index 0000000000..ef7885268e --- /dev/null +++ b/internal/controller/standalone_pgadmin/config.go @@ -0,0 +1,20 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +const ( + // key for standalone pgAdmin settings + settingsConfigMapKey = "pgadmin-settings.json" +) diff --git a/internal/controller/standalone_pgadmin/configmap.go b/internal/controller/standalone_pgadmin/configmap.go new file mode 100644 index 0000000000..a3632b5ed1 --- /dev/null +++ b/internal/controller/standalone_pgadmin/configmap.go @@ -0,0 +1,65 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + + "github.com/pkg/errors" + + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// +kubebuilder:rbac:groups="",resources="configmaps",verbs={get} +// +kubebuilder:rbac:groups="",resources="configmaps",verbs={create,delete,patch} + +// reconcilePGAdminConfigMap writes the ConfigMap for pgAdmin. +func (r *PGAdminReconciler) reconcilePGAdminConfigMap( + ctx context.Context, pgadmin *v1beta1.PGAdmin, +) (*corev1.ConfigMap, error) { + configmap := configmap(pgadmin) + + err := errors.WithStack(r.setControllerReference(pgadmin, configmap)) + + if err == nil { + err = errors.WithStack(r.apply(ctx, configmap)) + } + + return configmap, err +} + +// configmap returns a v1.ConfigMap for pgAdmin. +func configmap(pgadmin *v1beta1.PGAdmin) *corev1.ConfigMap { + configmap := &corev1.ConfigMap{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} + configmap.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + + configmap.Annotations = pgadmin.Spec.Metadata.GetAnnotationsOrNil() + configmap.Labels = naming.Merge( + pgadmin.Spec.Metadata.GetLabelsOrNil(), + map[string]string{ + naming.LabelStandalonePGAdmin: pgadmin.Name, + naming.LabelRole: naming.RolePGAdmin, + }) + + // TODO(tjmoore4): Populate configuration details. + initialize.StringMap(&configmap.Data) + configmap.Data[settingsConfigMapKey] = "config data" + + return configmap +} diff --git a/internal/controller/standalone_pgadmin/configmap_test.go b/internal/controller/standalone_pgadmin/configmap_test.go new file mode 100644 index 0000000000..47dc5a98b0 --- /dev/null +++ b/internal/controller/standalone_pgadmin/configmap_test.go @@ -0,0 +1,76 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestGeneratePGAdminConfigMap(t *testing.T) { + require.ParallelCapacity(t, 0) + + pgadmin := new(v1beta1.PGAdmin) + pgadmin.Namespace = "some-ns" + pgadmin.Name = "pg1" + + t.Run("Data,ObjectMeta,TypeMeta", func(t *testing.T) { + pgadmin := pgadmin.DeepCopy() + + configmap := configmap(pgadmin) + + assert.Assert(t, cmp.MarshalMatches(configmap.TypeMeta, ` +apiVersion: v1 +kind: ConfigMap + `)) + assert.Assert(t, cmp.MarshalMatches(configmap.ObjectMeta, ` +creationTimestamp: null +labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/standalone-pgadmin: pg1 +name: pg1-standalone-pgadmin +namespace: some-ns + `)) + + assert.Assert(t, len(configmap.Data) > 0, "expected some configuration") + }) + + t.Run("Annotations,Labels", func(t *testing.T) { + pgadmin := pgadmin.DeepCopy() + pgadmin.Spec.Metadata = &v1beta1.Metadata{ + Annotations: map[string]string{"a": "v1", "b": "v2"}, + Labels: map[string]string{"c": "v3", "d": "v4"}, + } + + configmap := configmap(pgadmin) + + // Annotations present in the metadata. + assert.DeepEqual(t, configmap.ObjectMeta.Annotations, map[string]string{ + "a": "v1", "b": "v2", + }) + + // Labels present in the metadata. + assert.DeepEqual(t, configmap.ObjectMeta.Labels, map[string]string{ + "c": "v3", "d": "v4", + "postgres-operator.crunchydata.com/standalone-pgadmin": "pg1", + "postgres-operator.crunchydata.com/role": "pgadmin", + }) + }) +} diff --git a/internal/controller/standalone_pgadmin/controller.go b/internal/controller/standalone_pgadmin/controller.go index 6b01413691..d60d7444c0 100644 --- a/internal/controller/standalone_pgadmin/controller.go +++ b/internal/controller/standalone_pgadmin/controller.go @@ -17,9 +17,13 @@ package standalone_pgadmin import ( "context" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -28,8 +32,9 @@ import ( // PGAdminReconciler reconciles a PGAdmin object type PGAdminReconciler struct { client.Client - Owner client.FieldOwner - Scheme *runtime.Scheme + Owner client.FieldOwner + Recorder record.EventRecorder + Scheme *runtime.Scheme } //+kubebuilder:rbac:groups=postgres-operator.crunchydata.com,resources=pgadmins,verbs=get;list;watch;create;update;patch;delete @@ -40,22 +45,77 @@ type PGAdminReconciler struct { // desired state described in a [v1beta1.PGAdmin] identified by request. func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var err error log := logging.FromContext(ctx) pgAdmin := &v1beta1.PGAdmin{} if err := r.Get(ctx, req.NamespacedName, pgAdmin); err != nil { - if err = client.IgnoreNotFound(err); err != nil { - log.Error(err, "unable to fetch PGAdmin") + // NotFound cannot be fixed by requeuing so ignore it. During background + // deletion, we receive delete events from pgadmin's dependents after + // pgadmin is deleted. + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Write any changes to the pgadmin status on the way out. + before := pgAdmin.DeepCopy() + defer func() { + if !equality.Semantic.DeepEqual(before.Status, pgAdmin.Status) { + statusErr := r.Status().Patch(ctx, pgAdmin, client.MergeFrom(before), r.Owner) + if statusErr != nil { + log.Error(statusErr, "Patching PGAdmin status") + } + if err == nil { + err = statusErr + } } - return ctrl.Result{}, err + }() + + var configmap *corev1.ConfigMap + var dataVolume *corev1.PersistentVolumeClaim + + if err == nil { + configmap, err = r.reconcilePGAdminConfigMap(ctx, pgAdmin) + } + if err == nil { + dataVolume, err = r.reconcilePGAdminDataVolume(ctx, pgAdmin) + } + if err == nil { + err = r.reconcilePGAdminStatefulSet(ctx, pgAdmin, configmap, dataVolume) + } + + if err == nil { + // at this point everything reconciled successfully, and we can update the + // observedGeneration + pgAdmin.Status.ObservedGeneration = pgAdmin.GetGeneration() + log.V(1).Info("reconciled cluster") } - log.Info("Reconciling pgAdmin") - return ctrl.Result{}, nil + + return ctrl.Result{}, err } // SetupWithManager sets up the controller with the Manager. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. func (r *PGAdminReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1beta1.PGAdmin{}). Complete(r) } + +// The owner reference created by controllerutil.SetControllerReference blocks +// deletion. The OwnerReferencesPermissionEnforcement plugin requires that the +// creator of such a reference have either "delete" permission on the owner or +// "update" permission on the owner's "finalizers" subresource. +// - https://docs.k8s.io/reference/access-authn-authz/admission-controllers/ +// +kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins/finalizers",verbs={update} + +// setControllerReference sets owner as a Controller OwnerReference on controlled. +// Only one OwnerReference can be a controller, so it returns an error if another +// is already set. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func (r *PGAdminReconciler) setControllerReference( + owner *v1beta1.PGAdmin, controlled client.Object, +) error { + return controllerutil.SetControllerReference(owner, controlled, r.Client.Scheme()) +} diff --git a/internal/controller/standalone_pgadmin/helpers_test.go b/internal/controller/standalone_pgadmin/helpers_test.go new file mode 100644 index 0000000000..1027cee894 --- /dev/null +++ b/internal/controller/standalone_pgadmin/helpers_test.go @@ -0,0 +1,147 @@ +//go:build envtest +// +build envtest + +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/internal/controller/runtime" +) + +// Scale extends d according to PGO_TEST_TIMEOUT_SCALE. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +var Scale = func(d time.Duration) time.Duration { return d } + +func init() { + setting := os.Getenv("PGO_TEST_TIMEOUT_SCALE") + factor, _ := strconv.ParseFloat(setting, 64) + + if setting != "" { + if factor <= 0 { + panic("PGO_TEST_TIMEOUT_SCALE must be a fractional number greater than zero") + } + + Scale = func(d time.Duration) time.Duration { + return time.Duration(factor * float64(d)) + } + } +} + +var kubernetes struct { + sync.Mutex + + env *envtest.Environment + count int +} + +// setupKubernetes starts or connects to a Kubernetes API and returns a client +// that uses it. When starting a local API, the client is a member of the +// "system:masters" group. It also creates any CRDs present in the +// "/config/crd/bases" directory. When any of these fail, it calls t.Fatal. +// It deletes CRDs and stops the local API using t.Cleanup. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func setupKubernetes(t testing.TB) client.Client { + t.Helper() + + kubernetes.Lock() + defer kubernetes.Unlock() + + if kubernetes.env == nil { + env := &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "config", "crd", "bases"), + }, + } + + _, err := env.Start() + assert.NilError(t, err) + + kubernetes.env = env + } + + kubernetes.count++ + + t.Cleanup(func() { + kubernetes.Lock() + defer kubernetes.Unlock() + + if t.Failed() { + if cc, err := client.New(kubernetes.env.Config, client.Options{}); err == nil { + var namespaces corev1.NamespaceList + _ = cc.List(context.Background(), &namespaces, client.HasLabels{"postgres-operator-test"}) + + type shaped map[string]corev1.NamespaceStatus + result := make([]shaped, len(namespaces.Items)) + + for i, ns := range namespaces.Items { + result[i] = shaped{ns.Labels["postgres-operator-test"]: ns.Status} + } + + formatted, _ := yaml.Marshal(result) + t.Logf("Test Namespaces:\n%s", formatted) + } + } + + kubernetes.count-- + + if kubernetes.count == 0 { + assert.Check(t, kubernetes.env.Stop()) + kubernetes.env = nil + } + }) + + scheme, err := runtime.CreatePostgresOperatorScheme() + assert.NilError(t, err) + + client, err := client.New(kubernetes.env.Config, client.Options{Scheme: scheme}) + assert.NilError(t, err) + + return client +} + +// setupNamespace creates a random namespace that will be deleted by t.Cleanup. +// When creation fails, it calls t.Fatal. The caller may delete the namespace +// at any time. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func setupNamespace(t testing.TB, cc client.Client) *corev1.Namespace { + t.Helper() + ns := &corev1.Namespace{} + ns.GenerateName = "postgres-operator-test-" + ns.Labels = map[string]string{"postgres-operator-test": t.Name()} + + ctx := context.Background() + assert.NilError(t, cc.Create(ctx, ns)) + t.Cleanup(func() { assert.Check(t, client.IgnoreNotFound(cc.Delete(ctx, ns))) }) + + return ns +} diff --git a/internal/controller/standalone_pgadmin/pod.go b/internal/controller/standalone_pgadmin/pod.go new file mode 100644 index 0000000000..9b7eade2e0 --- /dev/null +++ b/internal/controller/standalone_pgadmin/pod.go @@ -0,0 +1,131 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/config" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// pod populates a PodSpec with the container and volumes needed to run pgAdmin. +func pod( + inPGAdmin *v1beta1.PGAdmin, + inConfigMap *corev1.ConfigMap, + outPod *corev1.PodSpec, + pgAdminVolume *corev1.PersistentVolumeClaim, +) { + const ( + // config and data volume names + configVolumeName = "standalone-pgadmin-config" + dataVolumeName = "standalone-pgadmin-data" + ) + + // create the pgAdmin Pod volumes + pgAdminData := corev1.Volume{Name: dataVolumeName} + pgAdminData.VolumeSource = corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pgAdminVolume.Name, + ReadOnly: false, + }, + } + + configVolume := corev1.Volume{Name: configVolumeName} + configVolume.Projected = &corev1.ProjectedVolumeSource{ + Sources: podConfigFiles(inConfigMap, *inPGAdmin), + } + + // pgadmin container + container := corev1.Container{ + Name: naming.ContainerPGAdmin, + // TODO(tjmoore4): Update command and image details + Command: []string{"bash", "-c", "while true; do echo 'Hello!'; sleep 2; done"}, + Image: config.StandalonePGAdminContainerImage(inPGAdmin), + ImagePullPolicy: inPGAdmin.Spec.ImagePullPolicy, + Resources: inPGAdmin.Spec.Resources, + + SecurityContext: initialize.RestrictedSecurityContext(), + + Ports: []corev1.ContainerPort{{ + Name: naming.PortPGAdmin, + ContainerPort: int32(5050), + Protocol: corev1.ProtocolTCP, + }}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: configVolumeName, + MountPath: "/etc/pgadmin/conf.d", + ReadOnly: true, + }, + { + Name: dataVolumeName, + MountPath: "/var/lib/pgadmin", + }, + }, + } + + // add volumes and containers + outPod.Volumes = []corev1.Volume{pgAdminData, configVolume} + outPod.Containers = []corev1.Container{container} +} + +// podConfigFiles returns projections of pgAdmin's configuration files to +// include in the configuration volume. +func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []corev1.VolumeProjection { + config := append(append([]corev1.VolumeProjection{}, pgadmin.Spec.Config.Files...), + []corev1.VolumeProjection{ + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configmap.Name, + }, + Items: []corev1.KeyToPath{ + { + Key: settingsConfigMapKey, + Path: "~postgres-operator/pgadmin.json", + }, + }, + }, + }, + }...) + + // To enable LDAP authentication for pgAdmin, various LDAP settings must be configured. + // While most of the required configuration can be set using the 'settings' + // feature on the spec (.Spec.UserInterface.PGAdmin.Config.Settings), those + // values are stored in a ConfigMap in plaintext. + // As a special case, here we mount a provided Secret containing the LDAP_BIND_PASSWORD + // for use with the other pgAdmin LDAP configuration. + // - https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html + // - https://www.pgadmin.org/docs/pgadmin4/development/enabling_ldap_authentication.html + if pgadmin.Spec.Config.LDAPBindPassword != nil { + config = append(config, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: pgadmin.Spec.Config.LDAPBindPassword.LocalObjectReference, + Optional: pgadmin.Spec.Config.LDAPBindPassword.Optional, + Items: []corev1.KeyToPath{ + { + Key: pgadmin.Spec.Config.LDAPBindPassword.Key, + Path: "~postgres-operator/ldap-bind-password", + }, + }, + }, + }) + } + + return config +} diff --git a/internal/controller/standalone_pgadmin/pod_test.go b/internal/controller/standalone_pgadmin/pod_test.go new file mode 100644 index 0000000000..8a416a5f47 --- /dev/null +++ b/internal/controller/standalone_pgadmin/pod_test.go @@ -0,0 +1,172 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "testing" + + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPod(t *testing.T) { + t.Parallel() + + pgadmin := new(v1beta1.PGAdmin) + config := new(corev1.ConfigMap) + testpod := new(corev1.PodSpec) + pvc := new(corev1.PersistentVolumeClaim) + + call := func() { pod(pgadmin, config, testpod, pvc) } + + t.Run("Defaults", func(t *testing.T) { + + call() + + assert.Assert(t, cmp.MarshalMatches(testpod, ` +containers: +- command: + - bash + - -c + - while true; do echo 'Hello!'; sleep 2; done + name: pgadmin + ports: + - containerPort: 5050 + name: pgadmin + protocol: TCP + resources: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /etc/pgadmin/conf.d + name: standalone-pgadmin-config + readOnly: true + - mountPath: /var/lib/pgadmin + name: standalone-pgadmin-data +volumes: +- name: standalone-pgadmin-data + persistentVolumeClaim: + claimName: "" +- name: standalone-pgadmin-config + projected: + sources: + - configMap: + items: + - key: pgadmin-settings.json + path: ~postgres-operator/pgadmin.json +`)) + + // No change when called again. + before := testpod.DeepCopy() + call() + assert.DeepEqual(t, before, testpod) + }) + + t.Run("Customizations", func(t *testing.T) { + pgadmin.Spec.ImagePullPolicy = corev1.PullAlways + pgadmin.Spec.Image = initialize.String("new-image") + pgadmin.Spec.Resources.Requests = corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + } + + call() + + assert.Assert(t, cmp.MarshalMatches(testpod, ` +containers: +- command: + - bash + - -c + - while true; do echo 'Hello!'; sleep 2; done + image: new-image + imagePullPolicy: Always + name: pgadmin + ports: + - containerPort: 5050 + name: pgadmin + protocol: TCP + resources: + requests: + cpu: 100m + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - mountPath: /etc/pgadmin/conf.d + name: standalone-pgadmin-config + readOnly: true + - mountPath: /var/lib/pgadmin + name: standalone-pgadmin-data +volumes: +- name: standalone-pgadmin-data + persistentVolumeClaim: + claimName: "" +- name: standalone-pgadmin-config + projected: + sources: + - configMap: + items: + - key: pgadmin-settings.json + path: ~postgres-operator/pgadmin.json +`)) + }) +} + +func TestPodConfigFiles(t *testing.T) { + configmap := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "some-cm"}} + + pgadmin := v1beta1.PGAdmin{ + Spec: v1beta1.PGAdminSpec{ + Config: v1beta1.StandalonePGAdminConfiguration{Files: []corev1.VolumeProjection{{ + Secret: &corev1.SecretProjection{LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-secret", + }}, + }, { + ConfigMap: &corev1.ConfigMapProjection{LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }}, + }}}, + }, + } + + projections := podConfigFiles(configmap, pgadmin) + assert.Assert(t, cmp.MarshalMatches(projections, ` +- secret: + name: test-secret +- configMap: + name: test-cm +- configMap: + items: + - key: pgadmin-settings.json + path: ~postgres-operator/pgadmin.json + name: some-cm + `)) +} diff --git a/internal/controller/standalone_pgadmin/statefulset.go b/internal/controller/standalone_pgadmin/statefulset.go new file mode 100644 index 0000000000..c467e8a558 --- /dev/null +++ b/internal/controller/standalone_pgadmin/statefulset.go @@ -0,0 +1,112 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/pkg/errors" + + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// reconcilePGAdminStatefulSet writes the StatefulSet that runs pgAdmin. +func (r *PGAdminReconciler) reconcilePGAdminStatefulSet( + ctx context.Context, pgadmin *v1beta1.PGAdmin, + configmap *corev1.ConfigMap, dataVolume *corev1.PersistentVolumeClaim, +) error { + sts := statefulset(pgadmin, configmap, dataVolume) + if err := errors.WithStack(r.setControllerReference(pgadmin, sts)); err != nil { + return err + } + return errors.WithStack(r.apply(ctx, sts)) +} + +// statefulset defines the StatefulSet needed to run pgAdmin. +func statefulset( + pgadmin *v1beta1.PGAdmin, + configmap *corev1.ConfigMap, + dataVolume *corev1.PersistentVolumeClaim, +) *appsv1.StatefulSet { + sts := &appsv1.StatefulSet{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} + sts.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("StatefulSet")) + + sts.Annotations = pgadmin.Spec.Metadata.GetAnnotationsOrNil() + sts.Labels = naming.Merge( + pgadmin.Spec.Metadata.GetLabelsOrNil(), + naming.StandalonePGAdminCommonLabels(pgadmin), + ) + sts.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + naming.LabelStandalonePGAdmin: pgadmin.Name, + naming.LabelRole: naming.RoleStandalonePGAdmin, + }, + } + sts.Spec.Template.Annotations = pgadmin.Spec.Metadata.GetAnnotationsOrNil() + sts.Spec.Template.Labels = naming.Merge( + pgadmin.Spec.Metadata.GetLabelsOrNil(), + naming.StandalonePGAdminCommonLabels(pgadmin), + ) + + // Don't clutter the namespace with extra ControllerRevisions. + sts.Spec.RevisionHistoryLimit = initialize.Int32(0) + + // Give the Pod a stable DNS record based on its name. + // - https://docs.k8s.io/concepts/workloads/controllers/statefulset/#stable-network-id + // - https://docs.k8s.io/concepts/services-networking/dns-pod-service/#pods + sts.Spec.ServiceName = naming.StandalonePGAdminService(pgadmin).Name + + // Set the StatefulSet update strategy to "RollingUpdate", and the Partition size for the + // update strategy to 0 (note that these are the defaults for a StatefulSet). This means + // every pod of the StatefulSet will be deleted and recreated when the Pod template changes. + // - https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#rolling-updates + // - https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#forced-rollback + sts.Spec.UpdateStrategy.Type = appsv1.RollingUpdateStatefulSetStrategyType + sts.Spec.UpdateStrategy.RollingUpdate = &appsv1.RollingUpdateStatefulSetStrategy{ + Partition: initialize.Int32(0), + } + + // Use scheduling constraints from the cluster spec. + sts.Spec.Template.Spec.Affinity = pgadmin.Spec.Affinity + sts.Spec.Template.Spec.Tolerations = pgadmin.Spec.Tolerations + + if pgadmin.Spec.PriorityClassName != nil { + sts.Spec.Template.Spec.PriorityClassName = *pgadmin.Spec.PriorityClassName + } + + // Restart containers any time they stop, die, are killed, etc. + // - https://docs.k8s.io/concepts/workloads/pods/pod-lifecycle/#restart-policy + sts.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyAlways + + // pgAdmin does not make any Kubernetes API calls. Use the default + // ServiceAccount and do not mount its credentials. + sts.Spec.Template.Spec.AutomountServiceAccountToken = initialize.Bool(false) + + // Do not add environment variables describing services in this namespace. + sts.Spec.Template.Spec.EnableServiceLinks = initialize.Bool(false) + + // set the image pull secrets, if any exist + sts.Spec.Template.Spec.ImagePullSecrets = pgadmin.Spec.ImagePullSecrets + + pod(pgadmin, configmap, &sts.Spec.Template.Spec, dataVolume) + + return sts +} diff --git a/internal/controller/standalone_pgadmin/statefulset_test.go b/internal/controller/standalone_pgadmin/statefulset_test.go new file mode 100644 index 0000000000..b9d4c2b79c --- /dev/null +++ b/internal/controller/standalone_pgadmin/statefulset_test.go @@ -0,0 +1,216 @@ +//go:build envtest +// +build envtest + +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestReconcilePGAdminStatefulSet(t *testing.T) { + ctx := context.Background() + cc := setupKubernetes(t) + require.ParallelCapacity(t, 1) + + reconciler := &PGAdminReconciler{ + Client: cc, + Owner: client.FieldOwner(t.Name()), + } + + ns := setupNamespace(t, cc) + pgadmin := new(v1beta1.PGAdmin) + pgadmin.Name = "test-standalone-pgadmin" + pgadmin.Namespace = ns.Name + + assert.NilError(t, cc.Create(ctx, pgadmin)) + t.Cleanup(func() { assert.Check(t, cc.Delete(ctx, pgadmin)) }) + + configmap := &corev1.ConfigMap{} + configmap.Name = "test-cm" + + pvc := &corev1.PersistentVolumeClaim{} + pvc.Name = "test-pvc" + + t.Run("verify StatefulSet", func(t *testing.T) { + err := reconciler.reconcilePGAdminStatefulSet(ctx, pgadmin, configmap, pvc) + assert.NilError(t, err) + + selector, err := naming.AsSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + naming.LabelStandalonePGAdmin: pgadmin.Name, + }, + }) + assert.NilError(t, err) + + list := appsv1.StatefulSetList{} + assert.NilError(t, cc.List(ctx, &list, client.InNamespace(pgadmin.Namespace), + client.MatchingLabelsSelector{Selector: selector})) + assert.Equal(t, len(list.Items), 1) + + template := list.Items[0].Spec.Template.DeepCopy() + + // Containers and Volumes should be populated. + assert.Assert(t, len(template.Spec.Containers) != 0) + assert.Assert(t, len(template.Spec.Volumes) != 0) + + // Ignore Containers and Volumes in the comparison below. + template.Spec.Containers = nil + template.Spec.InitContainers = nil + template.Spec.Volumes = nil + + assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` +creationTimestamp: null +labels: + postgres-operator.crunchydata.com/data: standalone-pgadmin + postgres-operator.crunchydata.com/role: standalone-pgadmin + postgres-operator.crunchydata.com/standalone-pgadmin: test-standalone-pgadmin + `)) + + compare := ` +automountServiceAccountToken: false +containers: null +dnsPolicy: ClusterFirst +enableServiceLinks: false +restartPolicy: Always +schedulerName: default-scheduler +securityContext: {} +terminationGracePeriodSeconds: 30 + ` + + assert.Assert(t, cmp.MarshalMatches(template.Spec, compare)) + }) + + t.Run("verify customized deployment", func(t *testing.T) { + + custompgadmin := new(v1beta1.PGAdmin) + + // add pod level customizations + custompgadmin.Name = "custom-pgadmin" + custompgadmin.Namespace = ns.Name + + // annotation and label + custompgadmin.Spec.Metadata = &v1beta1.Metadata{ + Annotations: map[string]string{ + "annotation1": "annotationvalue", + }, + Labels: map[string]string{ + "label1": "labelvalue", + }, + } + + // scheduling constraints + custompgadmin.Spec.Affinity = &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "key", + Operator: "Exists", + }}, + }}, + }, + }, + } + custompgadmin.Spec.Tolerations = []corev1.Toleration{ + {Key: "sometoleration"}, + } + + if pgadmin.Spec.PriorityClassName != nil { + custompgadmin.Spec.PriorityClassName = initialize.String("testpriorityclass") + } + + // set an image pull secret + custompgadmin.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{ + Name: "myImagePullSecret"}} + + assert.NilError(t, cc.Create(ctx, custompgadmin)) + t.Cleanup(func() { assert.Check(t, cc.Delete(ctx, custompgadmin)) }) + + err := reconciler.reconcilePGAdminStatefulSet(ctx, custompgadmin, configmap, pvc) + assert.NilError(t, err) + + selector, err := naming.AsSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + naming.LabelStandalonePGAdmin: custompgadmin.Name, + }, + }) + assert.NilError(t, err) + + list := appsv1.StatefulSetList{} + assert.NilError(t, cc.List(ctx, &list, client.InNamespace(custompgadmin.Namespace), + client.MatchingLabelsSelector{Selector: selector})) + assert.Equal(t, len(list.Items), 1) + + template := list.Items[0].Spec.Template.DeepCopy() + + // Containers and Volumes should be populated. + assert.Assert(t, len(template.Spec.Containers) != 0) + + // Ignore Containers and Volumes in the comparison below. + template.Spec.Containers = nil + template.Spec.InitContainers = nil + template.Spec.Volumes = nil + + assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` +annotations: + annotation1: annotationvalue +creationTimestamp: null +labels: + label1: labelvalue + postgres-operator.crunchydata.com/data: standalone-pgadmin + postgres-operator.crunchydata.com/role: standalone-pgadmin + postgres-operator.crunchydata.com/standalone-pgadmin: custom-pgadmin + `)) + + compare := ` +affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: key + operator: Exists +automountServiceAccountToken: false +containers: null +dnsPolicy: ClusterFirst +enableServiceLinks: false +imagePullSecrets: +- name: myImagePullSecret +restartPolicy: Always +schedulerName: default-scheduler +securityContext: {} +terminationGracePeriodSeconds: 30 +tolerations: +- key: sometoleration +` + + assert.Assert(t, cmp.MarshalMatches(template.Spec, compare)) + }) +} diff --git a/internal/controller/standalone_pgadmin/volume.go b/internal/controller/standalone_pgadmin/volume.go new file mode 100644 index 0000000000..a0782a4358 --- /dev/null +++ b/internal/controller/standalone_pgadmin/volume.go @@ -0,0 +1,150 @@ +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/pkg/errors" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// +kubebuilder:rbac:groups="",resources="persistentvolumeclaims",verbs={create,patch} + +// reconcilePGAdminDataVolume writes the PersistentVolumeClaim for instance's +// pgAdmin data volume. +func (r *PGAdminReconciler) reconcilePGAdminDataVolume( + ctx context.Context, pgadmin *v1beta1.PGAdmin, +) (*corev1.PersistentVolumeClaim, error) { + + pvc := pvc(pgadmin) + + err := errors.WithStack(r.setControllerReference(pgadmin, pvc)) + + if err == nil { + err = r.handlePersistentVolumeClaimError(pgadmin, + errors.WithStack(r.apply(ctx, pvc))) + } + + return pvc, err +} + +// pvc defines the data volume for pgAdmin. +func pvc(pgadmin *v1beta1.PGAdmin) *corev1.PersistentVolumeClaim { + labelMap := map[string]string{ + naming.LabelStandalonePGAdmin: pgadmin.Name, + naming.LabelRole: naming.RoleStandalonePGAdmin, + naming.LabelData: naming.DataStandalonePGAdmin, + } + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} + pvc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim")) + + pvc.Annotations = pgadmin.Spec.Metadata.GetAnnotationsOrNil() + pvc.Labels = naming.Merge( + pgadmin.Spec.Metadata.GetLabelsOrNil(), + labelMap, + ) + pvc.Spec = pgadmin.Spec.DataVolumeClaimSpec + + return pvc +} + +// handlePersistentVolumeClaimError inspects err for expected Kubernetes API +// responses to writing a PVC. It turns errors it understands into conditions +// and events. When err is handled it returns nil. Otherwise it returns err. +// +// TODO(tjmoore4): This function is duplicated from a version that takes a PostgresCluster object. +func (r *PGAdminReconciler) handlePersistentVolumeClaimError( + pgadmin *v1beta1.PGAdmin, err error, +) error { + var status metav1.Status + if api := apierrors.APIStatus(nil); errors.As(err, &api) { + status = api.Status() + } + + cannotResize := func(err error) { + meta.SetStatusCondition(&pgadmin.Status.Conditions, metav1.Condition{ + Type: v1beta1.PersistentVolumeResizing, + Status: metav1.ConditionFalse, + Reason: string(apierrors.ReasonForError(err)), + Message: "One or more volumes cannot be resized", + + ObservedGeneration: pgadmin.Generation, + }) + } + + volumeError := func(err error) { + r.Recorder.Event(pgadmin, + corev1.EventTypeWarning, "PersistentVolumeError", err.Error()) + } + + // Forbidden means (RBAC is broken or) the API request was rejected by an + // admission controller. Assume it is the latter and raise the issue as a + // condition and event. + // - https://releases.k8s.io/v1.21.0/plugin/pkg/admission/storage/persistentvolume/resize/admission.go + if apierrors.IsForbidden(err) { + cannotResize(err) + volumeError(err) + return nil + } + + if apierrors.IsInvalid(err) && status.Details != nil { + unknownCause := false + for _, cause := range status.Details.Causes { + switch { + // Forbidden "spec" happens when the PVC is waiting to be bound. + // It should resolve on its own and trigger another reconcile. Raise + // the issue as an event. + // - https://releases.k8s.io/v1.21.0/pkg/apis/core/validation/validation.go#L2028 + // + // TODO(cbandy): This can also happen when changing a field other + // than requests within the spec (access modes, storage class, etc). + // That case needs a condition or should be prevented via a webhook. + case + cause.Type == metav1.CauseType(field.ErrorTypeForbidden) && + cause.Field == "spec": + volumeError(err) + + // Forbidden "storage" happens when the change is not allowed. Raise + // the issue as a condition and event. + // - https://releases.k8s.io/v1.21.0/pkg/apis/core/validation/validation.go#L2028 + case + cause.Type == metav1.CauseType(field.ErrorTypeForbidden) && + cause.Field == "spec.resources.requests.storage": + cannotResize(err) + volumeError(err) + + default: + unknownCause = true + } + } + + if len(status.Details.Causes) > 0 && !unknownCause { + // All the causes were identified and handled. + return nil + } + } + + return err +} diff --git a/internal/controller/standalone_pgadmin/volume_test.go b/internal/controller/standalone_pgadmin/volume_test.go new file mode 100644 index 0000000000..bd728e50b7 --- /dev/null +++ b/internal/controller/standalone_pgadmin/volume_test.go @@ -0,0 +1,307 @@ +//go:build envtest +// +build envtest + +// Copyright 2023 Crunchy Data Solutions, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standalone_pgadmin + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/pkg/errors" + + "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/events" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestReconcilePGAdminDataVolume(t *testing.T) { + ctx := context.Background() + cc := setupKubernetes(t) + require.ParallelCapacity(t, 1) + + reconciler := &PGAdminReconciler{ + Client: cc, + Owner: client.FieldOwner(t.Name()), + } + + ns := setupNamespace(t, cc) + pgadmin := &v1beta1.PGAdmin{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-standalone-pgadmin", + Namespace: ns.Name, + }, + Spec: v1beta1.PGAdminSpec{ + DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse("1Gi")}}, + StorageClassName: initialize.String("storage-class-for-data"), + }}} + + assert.NilError(t, cc.Create(ctx, pgadmin)) + t.Cleanup(func() { assert.Check(t, cc.Delete(ctx, pgadmin)) }) + + t.Run("DataVolume", func(t *testing.T) { + pvc, err := reconciler.reconcilePGAdminDataVolume(ctx, pgadmin) + assert.NilError(t, err) + + assert.Assert(t, metav1.IsControlledBy(pvc, pgadmin)) + + assert.Equal(t, pvc.Labels[naming.LabelStandalonePGAdmin], pgadmin.Name) + assert.Equal(t, pvc.Labels[naming.LabelRole], naming.RoleStandalonePGAdmin) + assert.Equal(t, pvc.Labels[naming.LabelData], naming.DataStandalonePGAdmin) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + requests: + storage: 1Gi +storageClassName: storage-class-for-data +volumeMode: Filesystem + `)) + }) +} + +func TestHandlePersistentVolumeClaimError(t *testing.T) { + scheme, err := runtime.CreatePostgresOperatorScheme() + assert.NilError(t, err) + + recorder := events.NewRecorder(t, scheme) + reconciler := &PGAdminReconciler{ + Recorder: recorder, + } + + pgadmin := new(v1beta1.PGAdmin) + pgadmin.Namespace = "ns1" + pgadmin.Name = "pg2" + + reset := func() { + pgadmin.Status.Conditions = pgadmin.Status.Conditions[:0] + recorder.Events = recorder.Events[:0] + } + + // It returns any error it does not recognize completely. + t.Run("Unexpected", func(t *testing.T) { + t.Cleanup(reset) + + err := errors.New("whomp") + + assert.Equal(t, err, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + assert.Assert(t, len(pgadmin.Status.Conditions) == 0) + assert.Assert(t, len(recorder.Events) == 0) + + err = apierrors.NewInvalid( + corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim").GroupKind(), + "some-pvc", + field.ErrorList{ + field.Forbidden(field.NewPath("metadata"), "dunno"), + }) + + assert.Equal(t, err, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + assert.Assert(t, len(pgadmin.Status.Conditions) == 0) + assert.Assert(t, len(recorder.Events) == 0) + }) + + // Neither statically nor dynamically provisioned claims can be resized + // before they are bound to a persistent volume. Kubernetes rejects such + // changes during PVC validation. + // + // A static PVC is one with a present-and-blank storage class. It is + // pending until a PV exists that matches its selector, requests, etc. + // - https://docs.k8s.io/concepts/storage/persistent-volumes/#static + // - https://docs.k8s.io/concepts/storage/persistent-volumes/#class-1 + // + // A dynamic PVC is associated with a storage class. Storage classes that + // "WaitForFirstConsumer" do not bind a PV until there is a pod. + // - https://docs.k8s.io/concepts/storage/persistent-volumes/#dynamic + t.Run("Pending", func(t *testing.T) { + t.Run("Grow", func(t *testing.T) { + t.Cleanup(reset) + + err := apierrors.NewInvalid( + corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim").GroupKind(), + "my-pending-pvc", + field.ErrorList{ + // - https://releases.k8s.io/v1.24.0/pkg/apis/core/validation/validation.go#L2184 + field.Forbidden(field.NewPath("spec"), "… immutable … bound claim …"), + }) + + // PVCs will bind eventually. This error should become an event without a condition. + assert.NilError(t, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + + assert.Check(t, len(pgadmin.Status.Conditions) == 0) + assert.Check(t, len(recorder.Events) > 0) + + for _, event := range recorder.Events { + assert.Equal(t, event.Type, "Warning") + assert.Equal(t, event.Reason, "PersistentVolumeError") + assert.Assert(t, cmp.Contains(event.Note, "PersistentVolumeClaim")) + assert.Assert(t, cmp.Contains(event.Note, "my-pending-pvc")) + assert.Assert(t, cmp.Contains(event.Note, "bound claim")) + assert.DeepEqual(t, event.Regarding, corev1.ObjectReference{ + APIVersion: v1beta1.GroupVersion.Identifier(), + Kind: "PGAdmin", + Namespace: "ns1", Name: "pg2", + }) + } + }) + + t.Run("Shrink", func(t *testing.T) { + t.Cleanup(reset) + + // Requests to make a pending PVC smaller fail for multiple reasons. + err := apierrors.NewInvalid( + corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim").GroupKind(), + "my-pending-pvc", + field.ErrorList{ + // - https://releases.k8s.io/v1.24.0/pkg/apis/core/validation/validation.go#L2184 + field.Forbidden(field.NewPath("spec"), "… immutable … bound claim …"), + + // - https://releases.k8s.io/v1.24.0/pkg/apis/core/validation/validation.go#L2188 + field.Forbidden(field.NewPath("spec", "resources", "requests", "storage"), "… not be less …"), + }) + + // PVCs will bind eventually, but the size is rejected. + assert.NilError(t, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + + assert.Check(t, len(pgadmin.Status.Conditions) > 0) + assert.Check(t, len(recorder.Events) > 0) + + for _, condition := range pgadmin.Status.Conditions { + assert.Equal(t, condition.Type, "PersistentVolumeResizing") + assert.Equal(t, condition.Status, metav1.ConditionFalse) + assert.Equal(t, condition.Reason, "Invalid") + assert.Assert(t, cmp.Contains(condition.Message, "cannot be resized")) + } + + for _, event := range recorder.Events { + assert.Equal(t, event.Type, "Warning") + assert.Equal(t, event.Reason, "PersistentVolumeError") + assert.Assert(t, cmp.Contains(event.Note, "PersistentVolumeClaim")) + assert.Assert(t, cmp.Contains(event.Note, "my-pending-pvc")) + assert.Assert(t, cmp.Contains(event.Note, "bound claim")) + assert.Assert(t, cmp.Contains(event.Note, "not be less")) + assert.DeepEqual(t, event.Regarding, corev1.ObjectReference{ + APIVersion: v1beta1.GroupVersion.Identifier(), + Kind: "PGAdmin", + Namespace: "ns1", Name: "pg2", + }) + } + }) + }) + + // Statically provisioned claims cannot be resized. Kubernetes responds + // differently based on the size growing or shrinking. + // + // Dynamically provisioned claims of storage classes that do *not* + // "allowVolumeExpansion" behave the same way. + t.Run("NoExpansion", func(t *testing.T) { + t.Run("Grow", func(t *testing.T) { + t.Cleanup(reset) + + // - https://releases.k8s.io/v1.24.0/plugin/pkg/admission/storage/persistentvolume/resize/admission.go#L108 + err := apierrors.NewForbidden( + corev1.Resource("persistentvolumeclaims"), "my-static-pvc", + errors.New("… only dynamically provisioned …")) + + // This PVC cannot resize. The error should become an event and condition. + assert.NilError(t, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + + assert.Check(t, len(pgadmin.Status.Conditions) > 0) + assert.Check(t, len(recorder.Events) > 0) + + for _, condition := range pgadmin.Status.Conditions { + assert.Equal(t, condition.Type, "PersistentVolumeResizing") + assert.Equal(t, condition.Status, metav1.ConditionFalse) + assert.Equal(t, condition.Reason, "Forbidden") + assert.Assert(t, cmp.Contains(condition.Message, "cannot be resized")) + } + + for _, event := range recorder.Events { + assert.Equal(t, event.Type, "Warning") + assert.Equal(t, event.Reason, "PersistentVolumeError") + assert.Assert(t, cmp.Contains(event.Note, "persistentvolumeclaim")) + assert.Assert(t, cmp.Contains(event.Note, "my-static-pvc")) + assert.Assert(t, cmp.Contains(event.Note, "only dynamic")) + assert.DeepEqual(t, event.Regarding, corev1.ObjectReference{ + APIVersion: v1beta1.GroupVersion.Identifier(), + Kind: "PGAdmin", + Namespace: "ns1", Name: "pg2", + }) + } + }) + + // Dynamically provisioned claims of storage classes that *do* + // "allowVolumeExpansion" can grow but cannot shrink. Kubernetes + // rejects such changes during PVC validation, just like static claims. + // + // A future version of Kubernetes will allow `spec.resources` to shrink + // so long as it is greater than `status.capacity`. + // - https://git.k8s.io/enhancements/keps/sig-storage/1790-recover-resize-failure + t.Run("Shrink", func(t *testing.T) { + t.Cleanup(reset) + + err := apierrors.NewInvalid( + corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim").GroupKind(), + "my-static-pvc", + field.ErrorList{ + // - https://releases.k8s.io/v1.24.0/pkg/apis/core/validation/validation.go#L2188 + field.Forbidden(field.NewPath("spec", "resources", "requests", "storage"), "… not be less …"), + }) + + // The PVC size is rejected. This error should become an event and condition. + assert.NilError(t, reconciler.handlePersistentVolumeClaimError(pgadmin, err)) + + assert.Check(t, len(pgadmin.Status.Conditions) > 0) + assert.Check(t, len(recorder.Events) > 0) + + for _, condition := range pgadmin.Status.Conditions { + assert.Equal(t, condition.Type, "PersistentVolumeResizing") + assert.Equal(t, condition.Status, metav1.ConditionFalse) + assert.Equal(t, condition.Reason, "Invalid") + assert.Assert(t, cmp.Contains(condition.Message, "cannot be resized")) + } + + for _, event := range recorder.Events { + assert.Equal(t, event.Type, "Warning") + assert.Equal(t, event.Reason, "PersistentVolumeError") + assert.Assert(t, cmp.Contains(event.Note, "PersistentVolumeClaim")) + assert.Assert(t, cmp.Contains(event.Note, "my-static-pvc")) + assert.Assert(t, cmp.Contains(event.Note, "not be less")) + assert.DeepEqual(t, event.Regarding, corev1.ObjectReference{ + APIVersion: v1beta1.GroupVersion.Identifier(), + Kind: "PGAdmin", + Namespace: "ns1", Name: "pg2", + }) + } + }) + }) +} diff --git a/internal/naming/controllers.go b/internal/naming/controllers.go index ef073faa18..5495f7f77b 100644 --- a/internal/naming/controllers.go +++ b/internal/naming/controllers.go @@ -16,5 +16,6 @@ package naming const ( - ControllerBridge = "bridge-controller" + ControllerBridge = "bridge-controller" + ControllerPGAdmin = "pgadmin-controller" ) diff --git a/internal/naming/labels.go b/internal/naming/labels.go index f1621cbcf6..9d4e80039e 100644 --- a/internal/naming/labels.go +++ b/internal/naming/labels.go @@ -145,6 +145,18 @@ const ( BackupReplicaCreate BackupJobType = "replica-create" ) +const ( + + // LabelStandalonePGAdmin is used to indicate a resource for a standalone-pgadmin instance. + LabelStandalonePGAdmin = labelPrefix + "standalone-pgadmin" + + // DataStandalonePGAdmin is a LabelData value that indicates the object has standalone-pgAdmin data. + DataStandalonePGAdmin = "standalone-pgadmin" + + // RoleStandalonePGAdmin is the LabelRole applied to standalone-pgAdmin objects. + RoleStandalonePGAdmin = "standalone-pgadmin" +) + // Merge takes sets of labels and merges them. The last set // provided will win in case of conflicts. func Merge(sets ...map[string]string) labels.Set { diff --git a/internal/naming/labels_test.go b/internal/naming/labels_test.go index 8cc02e17f2..ec4313e49a 100644 --- a/internal/naming/labels_test.go +++ b/internal/naming/labels_test.go @@ -44,6 +44,7 @@ func TestLabelsValid(t *testing.T) { assert.Assert(t, nil == validation.IsQualifiedName(LabelPGBackRestRestoreConfig)) assert.Assert(t, nil == validation.IsQualifiedName(LabelPGMonitorDiscovery)) assert.Assert(t, nil == validation.IsQualifiedName(LabelPostgresUser)) + assert.Assert(t, nil == validation.IsQualifiedName(LabelStandalonePGAdmin)) assert.Assert(t, nil == validation.IsQualifiedName(LabelStartupInstance)) } @@ -51,6 +52,7 @@ func TestLabelValuesValid(t *testing.T) { assert.Assert(t, nil == validation.IsValidLabelValue(DataPGAdmin)) assert.Assert(t, nil == validation.IsValidLabelValue(DataPGBackRest)) assert.Assert(t, nil == validation.IsValidLabelValue(DataPostgres)) + assert.Assert(t, nil == validation.IsValidLabelValue(DataStandalonePGAdmin)) assert.Assert(t, nil == validation.IsValidLabelValue(RolePatroniLeader)) assert.Assert(t, nil == validation.IsValidLabelValue(RolePatroniReplica)) assert.Assert(t, nil == validation.IsValidLabelValue(RolePGAdmin)) @@ -60,6 +62,7 @@ func TestLabelValuesValid(t *testing.T) { assert.Assert(t, nil == validation.IsValidLabelValue(RolePostgresWAL)) assert.Assert(t, nil == validation.IsValidLabelValue(RolePrimary)) assert.Assert(t, nil == validation.IsValidLabelValue(RoleReplica)) + assert.Assert(t, nil == validation.IsValidLabelValue(RoleStandalonePGAdmin)) assert.Assert(t, nil == validation.IsValidLabelValue(string(BackupReplicaCreate))) assert.Assert(t, nil == validation.IsValidLabelValue(RoleMonitoring)) } diff --git a/internal/naming/names.go b/internal/naming/names.go index b59d33a1f7..4841a685ee 100644 --- a/internal/naming/names.go +++ b/internal/naming/names.go @@ -568,6 +568,38 @@ func MovePGBackRestRepoDirJob(cluster *v1beta1.PostgresCluster) metav1.ObjectMet } } +// StandalonePGAdminCommonLabels returns the ObjectMeta used for the standalone +// pgAdmin StatefulSet and Pod. +func StandalonePGAdminCommonLabels(pgadmin *v1beta1.PGAdmin) map[string]string { + return map[string]string{ + LabelStandalonePGAdmin: pgadmin.Name, + LabelData: DataStandalonePGAdmin, + LabelRole: RoleStandalonePGAdmin, + } +} + +// StandalonePGAdmin returns the ObjectMeta necessary to lookup the ConfigMap, +// Service, StatefulSet, or Volume for the cluster's pgAdmin user interface. +func StandalonePGAdmin(pgadmin *v1beta1.PGAdmin) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Namespace: pgadmin.Namespace, + Name: pgadmin.Name + "-standalone-pgadmin", + } +} + +// StandalonePGAdminService returns the ObjectMeta necessary to lookup the Service +// that is responsible for the network identity of Pods. +func StandalonePGAdminService(pgadmin *v1beta1.PGAdmin) metav1.ObjectMeta { + // The hyphen below ensures that the DNS name will not be interpreted as a + // top-level domain. Partially qualified requests for "{pod}.{cluster}-pods" + // should not leave the Kubernetes cluster, and if they do they are less + // likely to resolve. + return metav1.ObjectMeta{ + Namespace: pgadmin.Namespace, + Name: pgadmin.Name + "-standalone-pods", + } +} + // UpgradeCheckConfigMap returns the ObjectMeta for the PGO ConfigMap func UpgradeCheckConfigMap() metav1.ObjectMeta { return metav1.ObjectMeta{ 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 0e2808aae6..c42208a980 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 @@ -19,12 +19,44 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// PGAdminConfiguration represents pgAdmin configuration files. +type StandalonePGAdminConfiguration struct { + // Files allows the user to mount projected volumes into the pgAdmin + // container so that files can be referenced by pgAdmin as needed. + Files []corev1.VolumeProjection `json:"files,omitempty"` + + // A Secret containing the value for the LDAP_BIND_PASSWORD setting. + // More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html + // +optional + LDAPBindPassword *corev1.SecretKeySelector `json:"ldapBindPassword,omitempty"` + + // 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 + Settings SchemalessObject `json:"settings,omitempty"` +} + // PGAdminSpec defines the desired state of PGAdmin type PGAdminSpec struct { // +optional Metadata *Metadata `json:"metadata,omitempty"` + // Configuration settings for the pgAdmin process. Changes to any of these + // values will be loaded without validation. Be careful, as + // you may put pgAdmin into an unusable state. + // +optional + Config StandalonePGAdminConfiguration `json:"config,omitempty"` + + // Defines a PersistentVolumeClaim for pgAdmin data. + // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + // +kubebuilder:validation:Required + DataVolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"dataVolumeClaimSpec"` + // The image name to use for standalone pgAdmin instance. // +optional Image *string `json:"image,omitempty"` @@ -57,6 +89,10 @@ type PGAdminSpec struct { // +optional PriorityClassName *string `json:"priorityClassName,omitempty"` + // Specification of the service that exposes pgAdmin. + // +optional + Service *ServiceSpec `json:"service,omitempty"` + // Tolerations of the PGAdmin pod. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration // +optional @@ -65,8 +101,20 @@ type PGAdminSpec struct { // PGAdminStatus defines the observed state of PGAdmin type PGAdminStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + + // conditions represent the observations of pgadmin's current state. + // Known .status.conditions.type are: "PersistentVolumeResizing", + // "Progressing", "ProxyAvailable" + // +optional + // +listType=map + // +listMapKey=type + // +operator-sdk:csv:customresourcedefinitions:type=status,xDescriptors={"urn:alm:descriptor:io.kubernetes.conditions"} + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // observedGeneration represents the .metadata.generation on which the status was based. + // +optional + // +kubebuilder:validation:Minimum=0 + ObservedGeneration int64 `json:"observedGeneration,omitempty"` } //+kubebuilder:object:root=true 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 afebe26087..8051ace2fc 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 @@ -289,7 +289,7 @@ func (in *PGAdmin) DeepCopyInto(out *PGAdmin) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGAdmin. @@ -450,6 +450,8 @@ func (in *PGAdminSpec) DeepCopyInto(out *PGAdminSpec) { *out = new(Metadata) (*in).DeepCopyInto(*out) } + in.Config.DeepCopyInto(&out.Config) + in.DataVolumeClaimSpec.DeepCopyInto(&out.DataVolumeClaimSpec) if in.Image != nil { in, out := &in.Image, &out.Image *out = new(string) @@ -471,6 +473,11 @@ func (in *PGAdminSpec) DeepCopyInto(out *PGAdminSpec) { *out = new(string) **out = **in } + if in.Service != nil { + in, out := &in.Service, &out.Service + *out = new(ServiceSpec) + (*in).DeepCopyInto(*out) + } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations *out = make([]v1.Toleration, len(*in)) @@ -493,6 +500,13 @@ func (in *PGAdminSpec) DeepCopy() *PGAdminSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGAdminStatus) DeepCopyInto(out *PGAdminStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGAdminStatus. @@ -1931,6 +1945,34 @@ func (in *Sidecar) DeepCopy() *Sidecar { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StandalonePGAdminConfiguration) DeepCopyInto(out *StandalonePGAdminConfiguration) { + *out = *in + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = make([]v1.VolumeProjection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LDAPBindPassword != nil { + in, out := &in.LDAPBindPassword, &out.LDAPBindPassword + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + in.Settings.DeepCopyInto(&out.Settings) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StandalonePGAdminConfiguration. +func (in *StandalonePGAdminConfiguration) DeepCopy() *StandalonePGAdminConfiguration { + if in == nil { + return nil + } + out := new(StandalonePGAdminConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TablespaceVolume) DeepCopyInto(out *TablespaceVolume) { *out = *in From 1a36cf68066014cdb37df4e4385ff0fb852b47e4 Mon Sep 17 00:00:00 2001 From: TJ Moore Date: Tue, 3 Oct 2023 14:49:47 -0400 Subject: [PATCH 2/2] Update PostgresCluster example to RWO Volumes --- examples/postgrescluster/postgrescluster.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/postgrescluster/postgrescluster.yaml b/examples/postgrescluster/postgrescluster.yaml index 02272f2901..58d3535741 100644 --- a/examples/postgrescluster/postgrescluster.yaml +++ b/examples/postgrescluster/postgrescluster.yaml @@ -9,7 +9,7 @@ spec: - name: instance1 dataVolumeClaimSpec: accessModes: - - "ReadWriteMany" + - "ReadWriteOnce" resources: requests: storage: 1Gi @@ -21,7 +21,7 @@ spec: volume: volumeClaimSpec: accessModes: - - "ReadWriteMany" + - "ReadWriteOnce" resources: requests: storage: 1Gi @@ -29,7 +29,7 @@ spec: volume: volumeClaimSpec: accessModes: - - "ReadWriteMany" + - "ReadWriteOnce" resources: requests: storage: 1Gi