postgresql: Deploy Postgres Operator
While I was preparing to deploy PostgreSQL for Firefly III, I was thinking it would be a neat idea to write an operator that uses custom resources to manage PostgreSQL roles and databases. Then I though, surely something like that must exist already. As it turns out, the [Postgres Operator][0] does exactly that, and a whole lot more. The *Postgres Operator* handles deploying PostgreSQL server instances, including primary/standby replication with load balancers. It uses custom resources to manage the databases and users (roles) in an instance, and stores role passwords in Secret resources. It supports backing up instances using `pg_basebackup` and WAL archives (i.e. physical backups) via [WAL-E][1]/[WAL-G][2]. While various backup storage targets are supported, *Postgres Operator* really only works well with the cloud storage services like S3, Azure, and Google Cloud Platform. Fortunately, S3-compatible on-premises solutions like MinIO are just fine. I think for my use cases, a single PostgreSQL cluster with multiple databases will be sufficient. I know *Firefly III* will need a PostgreSQL database, and I will likely want to migrate *Paperless-ngx* to PostgreSQL eventually too. Having a single instance will save on memory resources, at the cost of per-application point-in-time recovery. For now, just one server in the cluster is probably sufficient, but luckily adding standby servers appears to be really easy should the need arise. [0]: https://postgres-operator.readthedocs.io/en/latest/ [1]: https://github.com/wal-e/wal-e [2]: https://github.com/wal-g/wal-gdch-webhooks-secrets
parent
d8aadb01af
commit
ffffe9d3c8
|
@ -0,0 +1 @@
|
|||
pod.secrets
|
|
@ -0,0 +1,12 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-operator
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
targetPort: 8080
|
||||
selector:
|
||||
name: postgres-operator
|
|
@ -0,0 +1,18 @@
|
|||
apiVersion: acid.zalan.do/v1
|
||||
kind: postgresql
|
||||
metadata:
|
||||
name: default
|
||||
namespace: postgresql
|
||||
spec:
|
||||
teamId: acid
|
||||
volume:
|
||||
size: 10Gi
|
||||
numberOfInstances: 1
|
||||
postgresql:
|
||||
version: '15'
|
||||
users:
|
||||
dustin:
|
||||
- superuser
|
||||
- createdb
|
||||
databases:
|
||||
dustin: dustin
|
|
@ -0,0 +1,33 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: postgresql
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- operatorconfiguration.crd.yaml
|
||||
- postgresteam.crd.yaml
|
||||
- postgresql-operator-configuration.yaml
|
||||
- operator-service-account-rbac.yaml
|
||||
- postgres-operator.yaml
|
||||
- api-service.yaml
|
||||
- default-cluster.yaml
|
||||
|
||||
secretGenerator:
|
||||
- name: ssh-auth
|
||||
files:
|
||||
- ssh-backup.key
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: pod-secrets
|
||||
envs:
|
||||
- pod.secrets
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
configMapGenerator:
|
||||
- name: pod-env
|
||||
envs:
|
||||
- pod.env
|
||||
options:
|
||||
disableNameSuffixHash: true
|
|
@ -0,0 +1,4 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: postgresql
|
|
@ -0,0 +1,290 @@
|
|||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: postgres-operator
|
||||
namespace: default
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: postgres-operator
|
||||
rules:
|
||||
# all verbs allowed for custom operator resources
|
||||
- apiGroups:
|
||||
- acid.zalan.do
|
||||
resources:
|
||||
- postgresqls
|
||||
- postgresqls/status
|
||||
- operatorconfigurations
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- deletecollection
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# operator only reads PostgresTeams
|
||||
- apiGroups:
|
||||
- acid.zalan.do
|
||||
resources:
|
||||
- postgresteams
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# all verbs allowed for event streams (Zalando-internal feature)
|
||||
# - apiGroups:
|
||||
# - zalando.org
|
||||
# resources:
|
||||
# - fabriceventstreams
|
||||
# verbs:
|
||||
# - create
|
||||
# - delete
|
||||
# - deletecollection
|
||||
# - get
|
||||
# - list
|
||||
# - patch
|
||||
# - update
|
||||
# - watch
|
||||
# to create or get/update CRDs when starting up
|
||||
- apiGroups:
|
||||
- apiextensions.k8s.io
|
||||
resources:
|
||||
- customresourcedefinitions
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
# to read configuration from ConfigMaps
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- configmaps
|
||||
verbs:
|
||||
- get
|
||||
# to send events to the CRs
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# to manage endpoints which are also used by Patroni
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- endpoints
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- deletecollection
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# to CRUD secrets for database access
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- update
|
||||
# to check nodes for node readiness label
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- nodes
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# to read or delete existing PVCs. Creation via StatefulSet
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- persistentvolumeclaims
|
||||
verbs:
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
# to read existing PVs. Creation should be done via dynamic provisioning
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- persistentvolumes
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- update # only for resizing AWS volumes
|
||||
# to watch Spilo pods and do rolling updates. Creation via StatefulSet
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# to resize the filesystem in Spilo pods when increasing volume size
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods/exec
|
||||
verbs:
|
||||
- create
|
||||
# to CRUD services to point to Postgres cluster instances
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
# to CRUD the StatefulSet which controls the Postgres cluster instances
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
- deployments
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
# to CRUD cron jobs for logical backups
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- cronjobs
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
# to get namespaces operator resources can run in
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- namespaces
|
||||
verbs:
|
||||
- get
|
||||
# to define PDBs. Update happens via delete/create
|
||||
- apiGroups:
|
||||
- policy
|
||||
resources:
|
||||
- poddisruptionbudgets
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
# to create ServiceAccounts in each namespace the operator watches
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- serviceaccounts
|
||||
verbs:
|
||||
- get
|
||||
- create
|
||||
# to create role bindings to the postgres-pod service account
|
||||
- apiGroups:
|
||||
- rbac.authorization.k8s.io
|
||||
resources:
|
||||
- rolebindings
|
||||
verbs:
|
||||
- get
|
||||
- create
|
||||
# to grant privilege to run privileged pods (not needed by default)
|
||||
#- apiGroups:
|
||||
# - extensions
|
||||
# resources:
|
||||
# - podsecuritypolicies
|
||||
# resourceNames:
|
||||
# - privileged
|
||||
# verbs:
|
||||
# - use
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: postgres-operator
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: postgres-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: postgres-operator
|
||||
namespace: default
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: postgres-pod
|
||||
rules:
|
||||
# Patroni needs to watch and manage endpoints
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- endpoints
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- deletecollection
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# Patroni needs to watch pods
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
# to let Patroni create a headless service
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- services
|
||||
verbs:
|
||||
- create
|
||||
# to grant privilege to run privileged pods (not needed by default)
|
||||
#- apiGroups:
|
||||
# - extensions
|
||||
# resources:
|
||||
# - podsecuritypolicies
|
||||
# resourceNames:
|
||||
# - privileged
|
||||
# verbs:
|
||||
# - use
|
|
@ -0,0 +1,677 @@
|
|||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: operatorconfigurations.acid.zalan.do
|
||||
spec:
|
||||
group: acid.zalan.do
|
||||
names:
|
||||
kind: OperatorConfiguration
|
||||
listKind: OperatorConfigurationList
|
||||
plural: operatorconfigurations
|
||||
singular: operatorconfiguration
|
||||
shortNames:
|
||||
- opconfig
|
||||
categories:
|
||||
- all
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
additionalPrinterColumns:
|
||||
- name: Image
|
||||
type: string
|
||||
description: Spilo image to be used for Pods
|
||||
jsonPath: .configuration.docker_image
|
||||
- name: Cluster-Label
|
||||
type: string
|
||||
description: Label for K8s resources created by operator
|
||||
jsonPath: .configuration.kubernetes.cluster_name_label
|
||||
- name: Service-Account
|
||||
type: string
|
||||
description: Name of service account to be used
|
||||
jsonPath: .configuration.kubernetes.pod_service_account_name
|
||||
- name: Min-Instances
|
||||
type: integer
|
||||
description: Minimum number of instances per Postgres cluster
|
||||
jsonPath: .configuration.min_instances
|
||||
- name: Age
|
||||
type: date
|
||||
jsonPath: .metadata.creationTimestamp
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- apiVersion
|
||||
- configuration
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- OperatorConfiguration
|
||||
apiVersion:
|
||||
type: string
|
||||
enum:
|
||||
- acid.zalan.do/v1
|
||||
configuration:
|
||||
type: object
|
||||
properties:
|
||||
crd_categories:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
docker_image:
|
||||
type: string
|
||||
default: "ghcr.io/zalando/spilo-15:3.0-p1"
|
||||
enable_crd_registration:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_crd_validation:
|
||||
type: boolean
|
||||
description: deprecated
|
||||
default: true
|
||||
enable_lazy_spilo_upgrade:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_pgversion_env_var:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_shm_volume:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_spilo_wal_path_compat:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_team_id_clustername_prefix:
|
||||
type: boolean
|
||||
default: false
|
||||
etcd_host:
|
||||
type: string
|
||||
default: ""
|
||||
ignore_instance_limits_annotation_key:
|
||||
type: string
|
||||
kubernetes_use_configmaps:
|
||||
type: boolean
|
||||
default: false
|
||||
max_instances:
|
||||
type: integer
|
||||
description: "-1 = disabled"
|
||||
minimum: -1
|
||||
default: -1
|
||||
min_instances:
|
||||
type: integer
|
||||
description: "-1 = disabled"
|
||||
minimum: -1
|
||||
default: -1
|
||||
resync_period:
|
||||
type: string
|
||||
default: "30m"
|
||||
repair_period:
|
||||
type: string
|
||||
default: "5m"
|
||||
set_memory_request_to_limit:
|
||||
type: boolean
|
||||
default: false
|
||||
sidecar_docker_images:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
sidecars:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
workers:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 8
|
||||
users:
|
||||
type: object
|
||||
properties:
|
||||
additional_owner_roles:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
enable_password_rotation:
|
||||
type: boolean
|
||||
default: false
|
||||
password_rotation_interval:
|
||||
type: integer
|
||||
default: 90
|
||||
password_rotation_user_retention:
|
||||
type: integer
|
||||
default: 180
|
||||
replication_username:
|
||||
type: string
|
||||
default: standby
|
||||
super_username:
|
||||
type: string
|
||||
default: postgres
|
||||
major_version_upgrade:
|
||||
type: object
|
||||
properties:
|
||||
major_version_upgrade_mode:
|
||||
type: string
|
||||
default: "off"
|
||||
major_version_upgrade_team_allow_list:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minimal_major_version:
|
||||
type: string
|
||||
default: "11"
|
||||
target_major_version:
|
||||
type: string
|
||||
default: "15"
|
||||
kubernetes:
|
||||
type: object
|
||||
properties:
|
||||
additional_pod_capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
cluster_domain:
|
||||
type: string
|
||||
default: "cluster.local"
|
||||
cluster_labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
default:
|
||||
application: spilo
|
||||
cluster_name_label:
|
||||
type: string
|
||||
default: "cluster-name"
|
||||
custom_pod_annotations:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
delete_annotation_date_key:
|
||||
type: string
|
||||
delete_annotation_name_key:
|
||||
type: string
|
||||
downscaler_annotations:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enable_cross_namespace_secret:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_init_containers:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_pod_antiaffinity:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_pod_disruption_budget:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_readiness_probe:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_sidecars:
|
||||
type: boolean
|
||||
default: true
|
||||
ignored_annotations:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
infrastructure_roles_secret_name:
|
||||
type: string
|
||||
infrastructure_roles_secrets:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- secretname
|
||||
- userkey
|
||||
- passwordkey
|
||||
properties:
|
||||
secretname:
|
||||
type: string
|
||||
userkey:
|
||||
type: string
|
||||
passwordkey:
|
||||
type: string
|
||||
rolekey:
|
||||
type: string
|
||||
defaultuservalue:
|
||||
type: string
|
||||
defaultrolevalue:
|
||||
type: string
|
||||
details:
|
||||
type: string
|
||||
template:
|
||||
type: boolean
|
||||
inherited_annotations:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
inherited_labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
master_pod_move_timeout:
|
||||
type: string
|
||||
default: "20m"
|
||||
node_readiness_label:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
node_readiness_label_merge:
|
||||
type: string
|
||||
enum:
|
||||
- "AND"
|
||||
- "OR"
|
||||
oauth_token_secret_name:
|
||||
type: string
|
||||
default: "postgresql-operator"
|
||||
pdb_name_format:
|
||||
type: string
|
||||
default: "postgres-{cluster}-pdb"
|
||||
pod_antiaffinity_preferred_during_scheduling:
|
||||
type: boolean
|
||||
default: false
|
||||
pod_antiaffinity_topology_key:
|
||||
type: string
|
||||
default: "kubernetes.io/hostname"
|
||||
pod_environment_configmap:
|
||||
type: string
|
||||
pod_environment_secret:
|
||||
type: string
|
||||
pod_management_policy:
|
||||
type: string
|
||||
enum:
|
||||
- "ordered_ready"
|
||||
- "parallel"
|
||||
default: "ordered_ready"
|
||||
pod_priority_class_name:
|
||||
type: string
|
||||
pod_role_label:
|
||||
type: string
|
||||
default: "spilo-role"
|
||||
pod_service_account_definition:
|
||||
type: string
|
||||
default: ""
|
||||
pod_service_account_name:
|
||||
type: string
|
||||
default: "postgres-pod"
|
||||
pod_service_account_role_binding_definition:
|
||||
type: string
|
||||
default: ""
|
||||
pod_terminate_grace_period:
|
||||
type: string
|
||||
default: "5m"
|
||||
secret_name_template:
|
||||
type: string
|
||||
default: "{username}.{cluster}.credentials.{tprkind}.{tprgroup}"
|
||||
share_pgsocket_with_sidecars:
|
||||
type: boolean
|
||||
default: false
|
||||
spilo_allow_privilege_escalation:
|
||||
type: boolean
|
||||
default: true
|
||||
spilo_runasuser:
|
||||
type: integer
|
||||
spilo_runasgroup:
|
||||
type: integer
|
||||
spilo_fsgroup:
|
||||
type: integer
|
||||
spilo_privileged:
|
||||
type: boolean
|
||||
default: false
|
||||
storage_resize_mode:
|
||||
type: string
|
||||
enum:
|
||||
- "ebs"
|
||||
- "mixed"
|
||||
- "pvc"
|
||||
- "off"
|
||||
default: "pvc"
|
||||
toleration:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
watched_namespace:
|
||||
type: string
|
||||
postgres_pod_resources:
|
||||
type: object
|
||||
properties:
|
||||
default_cpu_limit:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "1"
|
||||
default_cpu_request:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "100m"
|
||||
default_memory_limit:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "500Mi"
|
||||
default_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "100Mi"
|
||||
max_cpu_request:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
max_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
min_cpu_limit:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "250m"
|
||||
min_memory_limit:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "250Mi"
|
||||
timeouts:
|
||||
type: object
|
||||
properties:
|
||||
patroni_api_check_interval:
|
||||
type: string
|
||||
default: "1s"
|
||||
patroni_api_check_timeout:
|
||||
type: string
|
||||
default: "5s"
|
||||
pod_label_wait_timeout:
|
||||
type: string
|
||||
default: "10m"
|
||||
pod_deletion_wait_timeout:
|
||||
type: string
|
||||
default: "10m"
|
||||
ready_wait_interval:
|
||||
type: string
|
||||
default: "4s"
|
||||
ready_wait_timeout:
|
||||
type: string
|
||||
default: "30s"
|
||||
resource_check_interval:
|
||||
type: string
|
||||
default: "3s"
|
||||
resource_check_timeout:
|
||||
type: string
|
||||
default: "10m"
|
||||
load_balancer:
|
||||
type: object
|
||||
properties:
|
||||
custom_service_annotations:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
db_hosted_zone:
|
||||
type: string
|
||||
default: "db.example.com"
|
||||
enable_master_load_balancer:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_master_pooler_load_balancer:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_replica_load_balancer:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_replica_pooler_load_balancer:
|
||||
type: boolean
|
||||
default: false
|
||||
external_traffic_policy:
|
||||
type: string
|
||||
enum:
|
||||
- "Cluster"
|
||||
- "Local"
|
||||
default: "Cluster"
|
||||
master_dns_name_format:
|
||||
type: string
|
||||
default: "{cluster}.{namespace}.{hostedzone}"
|
||||
master_legacy_dns_name_format:
|
||||
type: string
|
||||
default: "{cluster}.{team}.{hostedzone}"
|
||||
replica_dns_name_format:
|
||||
type: string
|
||||
default: "{cluster}-repl.{namespace}.{hostedzone}"
|
||||
replica_legacy_dns_name_format:
|
||||
type: string
|
||||
default: "{cluster}-repl.{team}.{hostedzone}"
|
||||
aws_or_gcp:
|
||||
type: object
|
||||
properties:
|
||||
additional_secret_mount:
|
||||
type: string
|
||||
additional_secret_mount_path:
|
||||
type: string
|
||||
default: "/meta/credentials"
|
||||
aws_region:
|
||||
type: string
|
||||
default: "eu-central-1"
|
||||
enable_ebs_gp3_migration:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_ebs_gp3_migration_max_size:
|
||||
type: integer
|
||||
default: 1000
|
||||
gcp_credentials:
|
||||
type: string
|
||||
kube_iam_role:
|
||||
type: string
|
||||
log_s3_bucket:
|
||||
type: string
|
||||
wal_az_storage_account:
|
||||
type: string
|
||||
wal_gs_bucket:
|
||||
type: string
|
||||
wal_s3_bucket:
|
||||
type: string
|
||||
logical_backup:
|
||||
type: object
|
||||
properties:
|
||||
logical_backup_azure_storage_account_name:
|
||||
type: string
|
||||
logical_backup_azure_storage_container:
|
||||
type: string
|
||||
logical_backup_azure_storage_account_key:
|
||||
type: string
|
||||
logical_backup_cpu_limit:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
logical_backup_cpu_request:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
logical_backup_docker_image:
|
||||
type: string
|
||||
default: "registry.opensource.zalan.do/acid/logical-backup:v1.10.0"
|
||||
logical_backup_google_application_credentials:
|
||||
type: string
|
||||
logical_backup_job_prefix:
|
||||
type: string
|
||||
default: "logical-backup-"
|
||||
logical_backup_memory_limit:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
logical_backup_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
logical_backup_provider:
|
||||
type: string
|
||||
enum:
|
||||
- "az"
|
||||
- "gcs"
|
||||
- "s3"
|
||||
default: "s3"
|
||||
logical_backup_s3_access_key_id:
|
||||
type: string
|
||||
logical_backup_s3_bucket:
|
||||
type: string
|
||||
logical_backup_s3_endpoint:
|
||||
type: string
|
||||
logical_backup_s3_region:
|
||||
type: string
|
||||
logical_backup_s3_secret_access_key:
|
||||
type: string
|
||||
logical_backup_s3_sse:
|
||||
type: string
|
||||
logical_backup_s3_retention_time:
|
||||
type: string
|
||||
logical_backup_schedule:
|
||||
type: string
|
||||
pattern: '^(\d+|\*)(/\d+)?(\s+(\d+|\*)(/\d+)?){4}$'
|
||||
default: "30 00 * * *"
|
||||
debug:
|
||||
type: object
|
||||
properties:
|
||||
debug_logging:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_database_access:
|
||||
type: boolean
|
||||
default: true
|
||||
teams_api:
|
||||
type: object
|
||||
properties:
|
||||
enable_admin_role_for_users:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_postgres_team_crd:
|
||||
type: boolean
|
||||
default: true
|
||||
enable_postgres_team_crd_superusers:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_team_member_deprecation:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_team_superuser:
|
||||
type: boolean
|
||||
default: false
|
||||
enable_teams_api:
|
||||
type: boolean
|
||||
default: true
|
||||
pam_configuration:
|
||||
type: string
|
||||
default: "https://info.example.com/oauth2/tokeninfo?access_token= uid realm=/employees"
|
||||
pam_role_name:
|
||||
type: string
|
||||
default: "zalandos"
|
||||
postgres_superuser_teams:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
protected_role_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
default:
|
||||
- admin
|
||||
- cron_admin
|
||||
role_deletion_suffix:
|
||||
type: string
|
||||
default: "_deleted"
|
||||
team_admin_role:
|
||||
type: string
|
||||
default: "admin"
|
||||
team_api_role_configuration:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
default:
|
||||
log_statement: all
|
||||
teams_api_url:
|
||||
type: string
|
||||
default: "https://teams.example.com/api/"
|
||||
logging_rest_api:
|
||||
type: object
|
||||
properties:
|
||||
api_port:
|
||||
type: integer
|
||||
default: 8080
|
||||
cluster_history_entries:
|
||||
type: integer
|
||||
default: 1000
|
||||
ring_log_lines:
|
||||
type: integer
|
||||
default: 100
|
||||
scalyr: # deprecated
|
||||
type: object
|
||||
properties:
|
||||
scalyr_api_key:
|
||||
type: string
|
||||
scalyr_cpu_limit:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "1"
|
||||
scalyr_cpu_request:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "100m"
|
||||
scalyr_image:
|
||||
type: string
|
||||
scalyr_memory_limit:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "500Mi"
|
||||
scalyr_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "50Mi"
|
||||
scalyr_server_url:
|
||||
type: string
|
||||
default: "https://upload.eu.scalyr.com"
|
||||
connection_pooler:
|
||||
type: object
|
||||
properties:
|
||||
connection_pooler_schema:
|
||||
type: string
|
||||
default: "pooler"
|
||||
connection_pooler_user:
|
||||
type: string
|
||||
default: "pooler"
|
||||
connection_pooler_image:
|
||||
type: string
|
||||
default: "registry.opensource.zalan.do/acid/pgbouncer:master-27"
|
||||
connection_pooler_max_db_connections:
|
||||
type: integer
|
||||
default: 60
|
||||
connection_pooler_mode:
|
||||
type: string
|
||||
enum:
|
||||
- "session"
|
||||
- "transaction"
|
||||
default: "transaction"
|
||||
connection_pooler_number_of_instances:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 2
|
||||
connection_pooler_default_cpu_limit:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "1"
|
||||
connection_pooler_default_cpu_request:
|
||||
type: string
|
||||
pattern: '^(\d+m|\d+(\.\d{1,3})?)$'
|
||||
default: "500m"
|
||||
connection_pooler_default_memory_limit:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "100Mi"
|
||||
connection_pooler_default_memory_request:
|
||||
type: string
|
||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||
default: "100Mi"
|
||||
patroni:
|
||||
type: object
|
||||
properties:
|
||||
enable_patroni_failsafe_mode:
|
||||
type: boolean
|
||||
default: false
|
||||
status:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
|
@ -0,0 +1,2 @@
|
|||
WAL_S3_BUCKET=pgbackup
|
||||
AWS_ENDPOINT=https://burp.pyrocufflink.blue:9000
|
|
@ -0,0 +1,45 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres-operator
|
||||
labels:
|
||||
application: postgres-operator
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: "Recreate"
|
||||
selector:
|
||||
matchLabels:
|
||||
name: postgres-operator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
name: postgres-operator
|
||||
spec:
|
||||
serviceAccountName: postgres-operator
|
||||
containers:
|
||||
- name: postgres-operator
|
||||
image: registry.opensource.zalan.do/acid/postgres-operator:v1.10.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 250Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 500Mi
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsNonRoot: true
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
env:
|
||||
# provided additional ENV vars can overwrite individual config map entries
|
||||
#- name: CONFIG_MAP_NAME
|
||||
# value: "postgres-operator"
|
||||
# In order to use the CRD OperatorConfiguration instead, uncomment these lines and comment out the two lines above
|
||||
- name: POSTGRES_OPERATOR_CONFIGURATION_OBJECT
|
||||
value: postgresql-operator-configuration
|
||||
# Define an ID to isolate controllers from each other
|
||||
# - name: CONTROLLER_ID
|
||||
# value: "second-operator"
|
|
@ -0,0 +1,213 @@
|
|||
apiVersion: "acid.zalan.do/v1"
|
||||
kind: OperatorConfiguration
|
||||
metadata:
|
||||
name: postgresql-operator-configuration
|
||||
configuration:
|
||||
docker_image: ghcr.io/zalando/spilo-15:3.0-p1
|
||||
# enable_crd_registration: true
|
||||
# crd_categories:
|
||||
# - all
|
||||
# enable_lazy_spilo_upgrade: false
|
||||
enable_pgversion_env_var: true
|
||||
# enable_shm_volume: true
|
||||
enable_spilo_wal_path_compat: false
|
||||
enable_team_id_clustername_prefix: false
|
||||
etcd_host: ""
|
||||
# ignore_instance_limits_annotation_key: ""
|
||||
# kubernetes_use_configmaps: false
|
||||
max_instances: -1
|
||||
min_instances: -1
|
||||
resync_period: 30m
|
||||
repair_period: 5m
|
||||
# set_memory_request_to_limit: false
|
||||
# sidecars:
|
||||
# - image: image:123
|
||||
# name: global-sidecar-1
|
||||
# ports:
|
||||
# - containerPort: 80
|
||||
# protocol: TCP
|
||||
workers: 2
|
||||
users:
|
||||
# additional_owner_roles:
|
||||
# - cron_admin
|
||||
enable_password_rotation: false
|
||||
password_rotation_interval: 90
|
||||
password_rotation_user_retention: 180
|
||||
replication_username: standby
|
||||
super_username: postgres
|
||||
major_version_upgrade:
|
||||
major_version_upgrade_mode: "off"
|
||||
# major_version_upgrade_team_allow_list:
|
||||
# - acid
|
||||
minimal_major_version: "11"
|
||||
target_major_version: "15"
|
||||
kubernetes:
|
||||
# additional_pod_capabilities:
|
||||
# - "SYS_NICE"
|
||||
cluster_domain: cluster.local
|
||||
cluster_labels:
|
||||
application: spilo
|
||||
cluster_name_label: cluster-name
|
||||
# custom_pod_annotations:
|
||||
# keya: valuea
|
||||
# keyb: valueb
|
||||
# delete_annotation_date_key: delete-date
|
||||
# delete_annotation_name_key: delete-clustername
|
||||
# downscaler_annotations:
|
||||
# - deployment-time
|
||||
# - downscaler/*
|
||||
enable_cross_namespace_secret: true
|
||||
enable_init_containers: true
|
||||
enable_pod_antiaffinity: false
|
||||
enable_pod_disruption_budget: true
|
||||
enable_readiness_probe: false
|
||||
enable_sidecars: true
|
||||
# ignored_annotations:
|
||||
# - k8s.v1.cni.cncf.io/network-status
|
||||
# infrastructure_roles_secret_name: "postgresql-infrastructure-roles"
|
||||
# infrastructure_roles_secrets:
|
||||
# - secretname: "monitoring-roles"
|
||||
# userkey: "user"
|
||||
# passwordkey: "password"
|
||||
# rolekey: "inrole"
|
||||
# - secretname: "other-infrastructure-role"
|
||||
# userkey: "other-user-key"
|
||||
# passwordkey: "other-password-key"
|
||||
# inherited_annotations:
|
||||
# - owned-by
|
||||
# inherited_labels:
|
||||
# - application
|
||||
# - environment
|
||||
master_pod_move_timeout: 20m
|
||||
# node_readiness_label:
|
||||
# status: ready
|
||||
# node_readiness_label_merge: "OR"
|
||||
oauth_token_secret_name: postgresql-operator
|
||||
pdb_name_format: "postgres-{cluster}-pdb"
|
||||
pod_antiaffinity_preferred_during_scheduling: false
|
||||
pod_antiaffinity_topology_key: "kubernetes.io/hostname"
|
||||
pod_environment_configmap: postgresql/pod-env
|
||||
pod_environment_secret: pod-secrets
|
||||
pod_management_policy: "ordered_ready"
|
||||
# pod_priority_class_name: "postgres-pod-priority"
|
||||
pod_role_label: spilo-role
|
||||
# pod_service_account_definition: ""
|
||||
pod_service_account_name: postgres-pod
|
||||
# pod_service_account_role_binding_definition: ""
|
||||
pod_terminate_grace_period: 5m
|
||||
secret_name_template: "{username}.{cluster}.credentials.{tprkind}.{tprgroup}"
|
||||
share_pgsocket_with_sidecars: false
|
||||
spilo_allow_privilege_escalation: true
|
||||
# spilo_runasuser: 101
|
||||
# spilo_runasgroup: 103
|
||||
# spilo_fsgroup: 103
|
||||
spilo_privileged: false
|
||||
storage_resize_mode: pvc
|
||||
# toleration:
|
||||
# key: db-only
|
||||
# operator: Exists
|
||||
# effect: NoSchedule
|
||||
# watched_namespace: ""
|
||||
postgres_pod_resources:
|
||||
default_cpu_limit: "1"
|
||||
default_cpu_request: 100m
|
||||
default_memory_limit: 500Mi
|
||||
default_memory_request: 100Mi
|
||||
# max_cpu_request: "1"
|
||||
# max_memory_request: 4Gi
|
||||
# min_cpu_limit: 250m
|
||||
# min_memory_limit: 250Mi
|
||||
timeouts:
|
||||
patroni_api_check_interval: 1s
|
||||
patroni_api_check_timeout: 5s
|
||||
pod_label_wait_timeout: 10m
|
||||
pod_deletion_wait_timeout: 10m
|
||||
ready_wait_interval: 4s
|
||||
ready_wait_timeout: 30s
|
||||
resource_check_interval: 3s
|
||||
resource_check_timeout: 10m
|
||||
load_balancer:
|
||||
# custom_service_annotations:
|
||||
# keyx: valuex
|
||||
# keyy: valuey
|
||||
# db_hosted_zone: ""
|
||||
enable_master_load_balancer: false
|
||||
enable_master_pooler_load_balancer: false
|
||||
enable_replica_load_balancer: false
|
||||
enable_replica_pooler_load_balancer: false
|
||||
external_traffic_policy: "Cluster"
|
||||
master_dns_name_format: "{cluster}.{namespace}.{hostedzone}"
|
||||
# master_legacy_dns_name_format: "{cluster}.{team}.{hostedzone}"
|
||||
replica_dns_name_format: "{cluster}-repl.{namespace}.{hostedzone}"
|
||||
# replica_dns_old_name_format: "{cluster}-repl.{team}.{hostedzone}"
|
||||
aws_or_gcp:
|
||||
additional_secret_mount: ssh-auth
|
||||
additional_secret_mount_path: /run/secrets/ssh-auth
|
||||
aws_region: eu-central-1
|
||||
enable_ebs_gp3_migration: false
|
||||
# enable_ebs_gp3_migration_max_size: 1000
|
||||
# gcp_credentials: ""
|
||||
# kube_iam_role: ""
|
||||
# log_s3_bucket: ""
|
||||
# wal_az_storage_account: ""
|
||||
# wal_gs_bucket: ""
|
||||
# wal_s3_bucket: ""
|
||||
logical_backup:
|
||||
# logical_backup_azure_storage_account_name: ""
|
||||
# logical_backup_azure_storage_container: ""
|
||||
# logical_backup_azure_storage_account_key: ""
|
||||
# logical_backup_cpu_limit: ""
|
||||
# logical_backup_cpu_request: ""
|
||||
# logical_backup_memory_limit: ""
|
||||
# logical_backup_memory_request: ""
|
||||
logical_backup_docker_image: "registry.opensource.zalan.do/acid/logical-backup:v1.10.0"
|
||||
# logical_backup_google_application_credentials: ""
|
||||
logical_backup_job_prefix: "logical-backup-"
|
||||
logical_backup_provider: "s3"
|
||||
# logical_backup_s3_access_key_id: ""
|
||||
logical_backup_s3_bucket: "my-bucket-url"
|
||||
# logical_backup_s3_endpoint: ""
|
||||
# logical_backup_s3_region: ""
|
||||
# logical_backup_s3_secret_access_key: ""
|
||||
logical_backup_s3_sse: "AES256"
|
||||
# logical_backup_s3_retention_time: ""
|
||||
logical_backup_schedule: "30 00 * * *"
|
||||
debug:
|
||||
debug_logging: true
|
||||
enable_database_access: true
|
||||
teams_api:
|
||||
# enable_admin_role_for_users: true
|
||||
# enable_postgres_team_crd: false
|
||||
# enable_postgres_team_crd_superusers: false
|
||||
enable_team_member_deprecation: false
|
||||
enable_team_superuser: false
|
||||
enable_teams_api: false
|
||||
# pam_configuration: ""
|
||||
pam_role_name: zalandos
|
||||
# postgres_superuser_teams:
|
||||
# - postgres_superusers
|
||||
protected_role_names:
|
||||
- admin
|
||||
- cron_admin
|
||||
role_deletion_suffix: "_deleted"
|
||||
team_admin_role: admin
|
||||
team_api_role_configuration:
|
||||
log_statement: all
|
||||
# teams_api_url: ""
|
||||
logging_rest_api:
|
||||
api_port: 8080
|
||||
cluster_history_entries: 1000
|
||||
ring_log_lines: 100
|
||||
connection_pooler:
|
||||
connection_pooler_default_cpu_limit: "1"
|
||||
connection_pooler_default_cpu_request: "500m"
|
||||
connection_pooler_default_memory_limit: 100Mi
|
||||
connection_pooler_default_memory_request: 100Mi
|
||||
connection_pooler_image: "registry.opensource.zalan.do/acid/pgbouncer:master-27"
|
||||
# connection_pooler_max_db_connections: 60
|
||||
connection_pooler_mode: "transaction"
|
||||
connection_pooler_number_of_instances: 2
|
||||
# connection_pooler_schema: "pooler"
|
||||
# connection_pooler_user: "pooler"
|
||||
patroni:
|
||||
enable_patroni_failsafe_mode: false
|
|
@ -0,0 +1,68 @@
|
|||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: postgresteams.acid.zalan.do
|
||||
spec:
|
||||
group: acid.zalan.do
|
||||
names:
|
||||
kind: PostgresTeam
|
||||
listKind: PostgresTeamList
|
||||
plural: postgresteams
|
||||
singular: postgresteam
|
||||
shortNames:
|
||||
- pgteam
|
||||
categories:
|
||||
- all
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- apiVersion
|
||||
- spec
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- PostgresTeam
|
||||
apiVersion:
|
||||
type: string
|
||||
enum:
|
||||
- acid.zalan.do/v1
|
||||
spec:
|
||||
type: object
|
||||
properties:
|
||||
additionalSuperuserTeams:
|
||||
type: object
|
||||
description: "Map for teamId and associated additional superuser teams"
|
||||
additionalProperties:
|
||||
type: array
|
||||
nullable: true
|
||||
description: "List of teams to become Postgres superusers"
|
||||
items:
|
||||
type: string
|
||||
additionalTeams:
|
||||
type: object
|
||||
description: "Map for teamId and associated additional teams"
|
||||
additionalProperties:
|
||||
type: array
|
||||
nullable: true
|
||||
description: "List of teams whose members will also be added to the Postgres cluster"
|
||||
items:
|
||||
type: string
|
||||
additionalMembers:
|
||||
type: object
|
||||
description: "Map for teamId and associated additional users"
|
||||
additionalProperties:
|
||||
type: array
|
||||
nullable: true
|
||||
description: "List of users who will also be added to the Postgres cluster"
|
||||
items:
|
||||
type: string
|
|
@ -0,0 +1,38 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: postgresql
|
||||
|
||||
resources:
|
||||
- github.com/zalando/postgres-operator/ui/manifests?ref=v1.9.0
|
||||
|
||||
patches:
|
||||
- patch: |-
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres-operator-ui
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: service
|
||||
env:
|
||||
- name: TARGET_NAMESPACE
|
||||
value: '*'
|
||||
- target:
|
||||
kind: Ingress
|
||||
labelSelector: application=postgres-operator-ui
|
||||
patch: |-
|
||||
- op: replace
|
||||
path: /spec/rules/0/host
|
||||
value: psqlui.pyrocufflink.blue
|
||||
- patch: |-
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: postgres-operator-ui
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-method: GET
|
||||
nginx.ingress.kubernetes.io/auth-url: http://authelia.authelia.svc.cluster.local:9091/api/verify
|
||||
nginx.ingress.kubernetes.io/auth-signin: https://auth.pyrocufflink.blue/?rm=$request_method
|
Loading…
Reference in New Issue