mirror of
https://github.com/zalando/postgres-operator.git
synced 2026-09-30 08:58:11 +02:00
Initial commit for Postgres Operator UI (#613)
* Initial commit for our basic Postgres Operator UI: * Create and modify Postgres manifests * Watch Operator Logs in the UI * Observe cluster creation progress * S3 Backup browser for clone and restore Many thanks to Manuel Gomez and Jan Mussler for the initial UI work a long time ago!
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# This version is replaced during release process.
|
||||
__version__ = '2017.0.dev1'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import random
|
||||
|
||||
|
||||
def expo(n: int, base=2, factor=1, max_value=None):
|
||||
"""Exponential decay.
|
||||
|
||||
Adapted from https://github.com/litl/backoff/blob/master/backoff.py (MIT License)
|
||||
|
||||
Args:
|
||||
base: The mathematical base of the exponentiation operation
|
||||
factor: Factor to multiply the exponentation by.
|
||||
max_value: The maximum value to yield. Once the value in the
|
||||
true exponential sequence exceeds this, the value
|
||||
of max_value will forever after be yielded.
|
||||
"""
|
||||
a = factor * base ** n
|
||||
if max_value is None or a < max_value:
|
||||
return a
|
||||
else:
|
||||
return max_value
|
||||
|
||||
|
||||
def random_jitter(value, jitter=1):
|
||||
"""Jitter the value a random number of milliseconds.
|
||||
|
||||
Copied from https://github.com/litl/backoff/blob/master/backoff.py (MIT License)
|
||||
|
||||
This adds up to 1 second of additional time to the original value.
|
||||
Prior to backoff version 1.2 this was the default jitter behavior.
|
||||
Args:
|
||||
value: The unadulterated backoff value.
|
||||
"""
|
||||
return value + random.uniform(0, jitter)
|
||||
|
||||
|
||||
def full_jitter(value):
|
||||
"""Jitter the value across the full range (0 to value).
|
||||
|
||||
Copied from https://github.com/litl/backoff/blob/master/backoff.py (MIT License)
|
||||
|
||||
This corresponds to the "Full Jitter" algorithm specified in the
|
||||
AWS blog's post on the performance of various jitter algorithms.
|
||||
(http://www.awsarchitectureblog.com/2015/03/backoff.html)
|
||||
|
||||
Args:
|
||||
value: The unadulterated backoff value.
|
||||
"""
|
||||
return random.uniform(0, value)
|
||||
@@ -0,0 +1,125 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import kubernetes.client
|
||||
import kubernetes.config
|
||||
import tokens
|
||||
from requests.auth import AuthBase
|
||||
|
||||
# default URL points to kubectl proxy
|
||||
DEFAULT_CLUSTERS = 'http://localhost:8001/'
|
||||
CLUSTER_ID_INVALID_CHARS = re.compile('[^a-z0-9:-]')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
tokens.configure(from_file_only=True)
|
||||
|
||||
|
||||
def generate_cluster_id(url: str):
|
||||
'''Generate some "cluster ID" from given API server URL'''
|
||||
for prefix in ('https://', 'http://'):
|
||||
if url.startswith(prefix):
|
||||
url = url[len(prefix):]
|
||||
return CLUSTER_ID_INVALID_CHARS.sub('-', url.lower()).strip('-')
|
||||
|
||||
|
||||
class StaticAuthorizationHeaderAuth(AuthBase):
|
||||
'''Static authentication with given "Authorization" header'''
|
||||
|
||||
def __init__(self, authorization):
|
||||
self.authorization = authorization
|
||||
|
||||
def __call__(self, request):
|
||||
request.headers['Authorization'] = self.authorization
|
||||
return request
|
||||
|
||||
|
||||
class OAuthTokenAuth(AuthBase):
|
||||
'''Dynamic authentication using the "tokens" library to load OAuth tokens from file
|
||||
(potentially mounted from a Kubernetes secret)'''
|
||||
|
||||
def __init__(self, token_name):
|
||||
self.token_name = token_name
|
||||
tokens.manage(token_name)
|
||||
|
||||
def __call__(self, request):
|
||||
token = tokens.get(self.token_name)
|
||||
request.headers['Authorization'] = 'Bearer {}'.format(token)
|
||||
return request
|
||||
|
||||
|
||||
class Cluster:
|
||||
def __init__(self, id, api_server_url, ssl_ca_cert=None, auth=None, cert_file=None, key_file=None):
|
||||
self.id = id
|
||||
self.api_server_url = api_server_url
|
||||
self.ssl_ca_cert = ssl_ca_cert
|
||||
self.auth = auth
|
||||
self.cert_file = cert_file
|
||||
self.key_file = key_file
|
||||
|
||||
|
||||
class StaticClusterDiscoverer:
|
||||
|
||||
def __init__(self, api_server_urls: list):
|
||||
self._clusters = []
|
||||
|
||||
if not api_server_urls:
|
||||
try:
|
||||
kubernetes.config.load_incluster_config()
|
||||
except kubernetes.config.ConfigException:
|
||||
# we are not running inside a cluster
|
||||
# => assume default kubectl proxy URL
|
||||
cluster = Cluster(generate_cluster_id(DEFAULT_CLUSTERS), DEFAULT_CLUSTERS)
|
||||
else:
|
||||
logger.info("in cluster configuration failed")
|
||||
config = kubernetes.client.configuration
|
||||
cluster = Cluster(
|
||||
generate_cluster_id(config.host),
|
||||
config.host,
|
||||
ssl_ca_cert=config.ssl_ca_cert,
|
||||
auth=StaticAuthorizationHeaderAuth(config.api_key['authorization']))
|
||||
self._clusters.append(cluster)
|
||||
else:
|
||||
for api_server_url in api_server_urls:
|
||||
logger.info("api server url: {}".format(api_server_url))
|
||||
if 'localhost' not in api_server_url:
|
||||
# TODO: hacky way of detecting whether we need a token or not
|
||||
auth = OAuthTokenAuth('read-only')
|
||||
else:
|
||||
auth = None
|
||||
self._clusters.append(Cluster(generate_cluster_id(api_server_url), api_server_url, auth=auth))
|
||||
|
||||
def get_clusters(self):
|
||||
return self._clusters
|
||||
|
||||
|
||||
class KubeconfigDiscoverer:
|
||||
|
||||
def __init__(self, kubeconfig_path: Path, contexts: set):
|
||||
self._path = kubeconfig_path
|
||||
self._contexts = contexts
|
||||
|
||||
def get_clusters(self):
|
||||
# Kubernetes Python client expects "vintage" string path
|
||||
config_file = str(self._path)
|
||||
contexts, current_context = kubernetes.config.list_kube_config_contexts(config_file)
|
||||
for context in contexts:
|
||||
if self._contexts and context['name'] not in self._contexts:
|
||||
# filter out
|
||||
continue
|
||||
config = kubernetes.client.ConfigurationObject()
|
||||
kubernetes.config.load_kube_config(config_file, context=context['name'], client_configuration=config)
|
||||
authorization = config.api_key.get('authorization')
|
||||
if authorization:
|
||||
auth = StaticAuthorizationHeaderAuth(authorization)
|
||||
else:
|
||||
auth = None
|
||||
cluster = Cluster(
|
||||
context['name'],
|
||||
config.host,
|
||||
ssl_ca_cert=config.ssl_ca_cert,
|
||||
cert_file=config.cert_file,
|
||||
key_file=config.key_file,
|
||||
auth=auth)
|
||||
yield cluster
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import time
|
||||
import json
|
||||
import request
|
||||
|
||||
|
||||
class MockCluster:
|
||||
|
||||
def get_pods(self):
|
||||
return [{"name": "cluster-1-XFF", "role": "master", "ip": "localhost", "port": "8080"},
|
||||
{"name": "cluster-1-XFE", "role": "replica", "ip": "localhost", "port": "8080"},
|
||||
{"name": "cluster-1-XFS", "role": "replica", "ip": "localhost", "port": "8080"},
|
||||
{"name": "cluster-2-SJE", "role": "master", "ip": "localhost", "port": "8080"}]
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
|
||||
from flask_oauthlib.client import OAuthRemoteApp
|
||||
|
||||
|
||||
CREDENTIALS_DIR = os.getenv('CREDENTIALS_DIR', '')
|
||||
|
||||
|
||||
class OAuthRemoteAppWithRefresh(OAuthRemoteApp):
|
||||
'''Same as flask_oauthlib.client.OAuthRemoteApp, but always loads client credentials from file.'''
|
||||
|
||||
def __init__(self, oauth, name, **kwargs):
|
||||
# constructor expects some values, so make it happy..
|
||||
kwargs['consumer_key'] = 'not-needed-here'
|
||||
kwargs['consumer_secret'] = 'not-needed-here'
|
||||
OAuthRemoteApp.__init__(self, oauth, name, **kwargs)
|
||||
|
||||
def refresh_credentials(self):
|
||||
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-id')) as fd:
|
||||
self._consumer_key = fd.read().strip()
|
||||
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-secret')) as fd:
|
||||
self._consumer_secret = fd.read().strip()
|
||||
|
||||
@property
|
||||
def consumer_key(self):
|
||||
self.refresh_credentials()
|
||||
return self._consumer_key
|
||||
|
||||
@property
|
||||
def consumer_secrect(self):
|
||||
self.refresh_credentials()
|
||||
return self._consumer_secret
|
||||
@@ -0,0 +1,312 @@
|
||||
from boto3 import client
|
||||
from datetime import datetime, timezone
|
||||
from furl import furl
|
||||
from json import dumps
|
||||
from logging import getLogger
|
||||
from os import environ
|
||||
from requests import Session
|
||||
from urllib.parse import urljoin
|
||||
from uuid import UUID
|
||||
from wal_e.cmd import configure_backup_cxt
|
||||
|
||||
from .utils import Attrs, defaulting, these
|
||||
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
session = Session()
|
||||
|
||||
|
||||
def request(cluster, path, **kwargs):
|
||||
if 'timeout' not in kwargs:
|
||||
# sane default timeout
|
||||
kwargs['timeout'] = (5, 15)
|
||||
if cluster.cert_file and cluster.key_file:
|
||||
kwargs['cert'] = (cluster.cert_file, cluster.key_file)
|
||||
|
||||
return session.get(
|
||||
urljoin(cluster.api_server_url, path),
|
||||
auth=cluster.auth,
|
||||
verify=cluster.ssl_ca_cert,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def request_post(cluster, path, data, **kwargs):
|
||||
if 'timeout' not in kwargs:
|
||||
# sane default timeout
|
||||
kwargs['timeout'] = 5
|
||||
if cluster.cert_file and cluster.key_file:
|
||||
kwargs['cert'] = (cluster.cert_file, cluster.key_file)
|
||||
|
||||
return session.post(
|
||||
urljoin(cluster.api_server_url, path),
|
||||
data=data,
|
||||
auth=cluster.auth,
|
||||
verify=cluster.ssl_ca_cert,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def request_put(cluster, path, data, **kwargs):
|
||||
if 'timeout' not in kwargs:
|
||||
# sane default timeout
|
||||
kwargs['timeout'] = 5
|
||||
if cluster.cert_file and cluster.key_file:
|
||||
kwargs['cert'] = (cluster.cert_file, cluster.key_file)
|
||||
|
||||
return session.put(
|
||||
urljoin(cluster.api_server_url, path),
|
||||
data=data,
|
||||
auth=cluster.auth,
|
||||
verify=cluster.ssl_ca_cert,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def request_delete(cluster, path, **kwargs):
|
||||
if 'timeout' not in kwargs:
|
||||
# sane default timeout
|
||||
kwargs['timeout'] = 5
|
||||
if cluster.cert_file and cluster.key_file:
|
||||
kwargs['cert'] = (cluster.cert_file, cluster.key_file)
|
||||
|
||||
return session.delete(
|
||||
urljoin(cluster.api_server_url, path),
|
||||
auth=cluster.auth,
|
||||
verify=cluster.ssl_ca_cert,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def resource_api_version(resource_type):
|
||||
return {
|
||||
'postgresqls': 'apis/acid.zalan.do/v1',
|
||||
'statefulsets': 'apis/apps/v1beta1',
|
||||
}.get(resource_type, 'api/v1')
|
||||
|
||||
|
||||
def encode_labels(label_selector):
|
||||
return ','.join([
|
||||
f'{label}={value}'
|
||||
for label, value in label_selector.items()
|
||||
])
|
||||
|
||||
|
||||
def kubernetes_url(
|
||||
resource_type,
|
||||
namespace='default',
|
||||
resource_name=None,
|
||||
label_selector=None,
|
||||
):
|
||||
|
||||
return furl('/').add(
|
||||
path=(
|
||||
resource_api_version(resource_type).split('/')
|
||||
+ (
|
||||
['namespaces', namespace]
|
||||
if namespace
|
||||
else []
|
||||
)
|
||||
+ [resource_type]
|
||||
+ (
|
||||
[resource_name]
|
||||
if resource_name
|
||||
else []
|
||||
)
|
||||
),
|
||||
args=(
|
||||
{'labelSelector': encode_labels(label_selector)}
|
||||
if label_selector
|
||||
else {}
|
||||
),
|
||||
).url
|
||||
|
||||
|
||||
def kubernetes_get(cluster, **kwargs):
|
||||
response = request(cluster, kubernetes_url(**kwargs))
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code >= 400:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def read_pods(cluster, namespace, spilo_cluster):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='pods',
|
||||
namespace=namespace,
|
||||
label_selector={'version': spilo_cluster},
|
||||
)
|
||||
|
||||
|
||||
def read_pod(cluster, namespace, resource_name):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='pods',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
label_selector={'application': 'spilo'},
|
||||
)
|
||||
|
||||
|
||||
def read_service(cluster, namespace, resource_name):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='services',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
label_selector={'application': 'spilo'},
|
||||
)
|
||||
|
||||
|
||||
def read_statefulset(cluster, namespace, resource_name):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='statefulsets',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
label_selector={'application': 'spilo'},
|
||||
)
|
||||
|
||||
|
||||
def read_postgresql(cluster, namespace, resource_name):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='postgresqls',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
)
|
||||
|
||||
|
||||
def read_postgresqls(cluster, namespace):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='postgresqls',
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
|
||||
def read_namespaces(cluster):
|
||||
return kubernetes_get(
|
||||
cluster=cluster,
|
||||
resource_type='namespaces',
|
||||
namespace=None,
|
||||
)
|
||||
|
||||
|
||||
def create_postgresql(cluster, namespace, definition):
|
||||
path = kubernetes_url(
|
||||
resource_type='postgresqls',
|
||||
namespace=namespace,
|
||||
)
|
||||
try:
|
||||
r = request_post(cluster, path, dumps(definition))
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.exception("K8S create request failed")
|
||||
return False
|
||||
|
||||
|
||||
def apply_postgresql(cluster, namespace, resource_name, definition):
|
||||
path = kubernetes_url(
|
||||
resource_type='postgresqls',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
)
|
||||
try:
|
||||
r = request_put(cluster, path, dumps(definition))
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.exception("K8S create request failed")
|
||||
return False
|
||||
|
||||
|
||||
def remove_postgresql(cluster, namespace, resource_name):
|
||||
path = kubernetes_url(
|
||||
resource_type='postgresqls',
|
||||
namespace=namespace,
|
||||
resource_name=resource_name,
|
||||
)
|
||||
try:
|
||||
r = request_delete(cluster, path)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as ex:
|
||||
logger.exception("K8S delete request failed")
|
||||
return False
|
||||
|
||||
|
||||
def read_stored_clusters(bucket, prefix, delimiter='/'):
|
||||
return [
|
||||
prefix['Prefix'].split('/')[-2]
|
||||
for prefix in these(
|
||||
client('s3').list_objects(
|
||||
Bucket=bucket,
|
||||
Delimiter=delimiter,
|
||||
Prefix=prefix,
|
||||
),
|
||||
'CommonPrefixes',
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def read_versions(
|
||||
pg_cluster,
|
||||
bucket,
|
||||
s3_endpoint,
|
||||
prefix,
|
||||
delimiter='/',
|
||||
use_aws_instance_profile=False,
|
||||
):
|
||||
return [
|
||||
'base' if uid == 'wal' else uid
|
||||
for prefix in these(
|
||||
client('s3').list_objects(
|
||||
Bucket=bucket,
|
||||
Delimiter=delimiter,
|
||||
Prefix=prefix + pg_cluster + delimiter,
|
||||
),
|
||||
'CommonPrefixes',
|
||||
)
|
||||
|
||||
for uid in [prefix['Prefix'].split('/')[-2]]
|
||||
|
||||
if uid == 'wal' or defaulting(lambda: UUID(uid))
|
||||
]
|
||||
|
||||
|
||||
def read_basebackups(
|
||||
pg_cluster,
|
||||
uid,
|
||||
bucket,
|
||||
s3_endpoint,
|
||||
prefix,
|
||||
delimiter='/',
|
||||
use_aws_instance_profile=False,
|
||||
):
|
||||
environ['WALE_S3_ENDPOINT'] = s3_endpoint
|
||||
suffix = '' if uid == 'base' else '/' + uid
|
||||
return [
|
||||
{
|
||||
key: value
|
||||
for key, value in basebackup.__dict__.items()
|
||||
if isinstance(value, str) or isinstance(value, int)
|
||||
}
|
||||
for basebackup in Attrs.call(
|
||||
f=configure_backup_cxt,
|
||||
aws_instance_profile=use_aws_instance_profile,
|
||||
s3_prefix=f's3://{bucket}/{prefix}{pg_cluster}{suffix}/wal/',
|
||||
)._backup_list(detail=True)
|
||||
]
|
||||
|
||||
|
||||
def parse_time(s: str):
|
||||
return (
|
||||
datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ')
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.timestamp()
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,139 @@
|
||||
/* http://prismjs.com/download.html?themes=prism&languages=yaml */
|
||||
/**
|
||||
* prism.js default theme for JavaScript, CSS and HTML
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection, code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: slategray;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #905;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #690;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #07a;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: #DD4A68;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
body {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
.font-robot {
|
||||
font-family: 'Roboto 300', sans-serif;
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
color: red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul.ips { list-style-type: none; margin: 0; padding: 0; overflow-x: hidden; }
|
||||
ul.ips li { margin: 0; padding: 0; }
|
||||
ul.ips label { margin: 0; padding: 0; }
|
||||
|
||||
.panel-heading.collapsible {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-heading .collapsible:after {
|
||||
color: grey;
|
||||
content: "\e113";
|
||||
float: right;
|
||||
font-family: 'Glyphicons Halflings';
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.panel-heading.collapsed .collapsible:after {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
:not(form):invalid,select.owner:disabled {
|
||||
border: 1px solid red;
|
||||
box-shadow: 0 0 10px red;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: normal;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sk-spinner-pulse {
|
||||
background-color: darkblue;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PostgreSQL Operator UI</title>
|
||||
|
||||
|
||||
<!-- fonts -->
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Open+Sans|Roboto:300,400"
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
|
||||
<!-- bootstrap -->
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css"
|
||||
integrity="sha256-916EbMg70RQy9LHiGkXzG8hSg9EdNy97GazNG/aiY1w="
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap-theme.min.css"
|
||||
integrity="sha256-ZT4HPpdCOt2lvDkXokHuhJfdOKSPFLzeAJik5U/Q+l4="
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
|
||||
<!-- other -->
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/blaze/3.5.2/blaze.min.css"
|
||||
integrity="sha256-5n+FnqayL2YJQucWyxvz4SzjhRqcKvgWE/sh/4FbPao="
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/spinkit/1.2.5/spinkit.min.css"
|
||||
integrity="sha256-JLf+H3os8xYfw2Iaq4Nv8MG6dVn1gPNv4EhSWnYG3rc="
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/jquery-confirm/3.3.2/jquery-confirm.min.css"
|
||||
integrity="sha256-mAmp1v6ERknmeP2oHZG53W1L+zOdSVsM25WvmZ4U+fU="
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.css"
|
||||
integrity="sha256-I1UoFd33KHIydu88R9owFaQWzwkiZV4hXXug5aYaM28="
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
|
||||
<!-- self-hosted -->
|
||||
<link href="/css/styles.css" rel="stylesheet">
|
||||
<link href="/css/prism.css" rel="stylesheet">
|
||||
<link href="/favicon.png" rel="icon" type="image/png">
|
||||
|
||||
</head>
|
||||
|
||||
<script>
|
||||
String.prototype.format = function() {
|
||||
var formatted = this;
|
||||
for(arg in arguments) {
|
||||
formatted = formatted.replace("{" + arg + "}", arguments[arg]);
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
</script>
|
||||
|
||||
<body>
|
||||
<app></app>
|
||||
|
||||
|
||||
<!-- jQuery -->
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
|
||||
src="https://code.jquery.com/jquery-3.2.1.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-0Uz1UklrpANuwqJ7M0Z54jiOE/GZwlp2EBSC6slw6j8="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jquery-confirm/3.3.2/jquery-confirm.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
|
||||
<!-- riotjs -->
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-L1wbqR0vvN36EcDq7wNvFk+uWm9WrMAFgSQAheWqu1g="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/riot/3.9.1/riot.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-dQVFCNWwV4WTuFCMRXLhLIITUiPzYul+Fz35TrYSbGQ="
|
||||
src="https://cdn.jsdelivr.net/npm/riot-route@3.1.3/dist/route+tag.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-EGKMPXH/+n6AHqghd+K5zE3e25X6hIIY0tG30ebuv4g="
|
||||
src="https://cdn.jsdelivr.net/npm/riotgear@3.5.0/dist/rg.min.js"
|
||||
></script>
|
||||
|
||||
|
||||
<!-- other -->
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
|
||||
src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-Daf8GuI2eLKHJlOWLRR/zRy9Clqcj4TUSumbxYH9kGI="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.7.1/clipboard.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-0JaDbGZRXlzkFbV8Xi8ZhH/zZ6QQM0Y3dCkYZ7JYq34="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.10/handlebars.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-vBJO3VoZ+1/7RMqHp9ENExY+RwfXHhi0WhX2uIOS5c0="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/humanize-duration/3.12.1/humanize-duration.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-wzBMoYcU9BZfRm6cQLFii4K5tkNptkER9p93W/vyCqo="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.21.0/moment-with-locales.min.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
integrity="sha256-ff7iz7mLH5QJA9IUC44b+sqjMi7c2aTR9YO2DSzAGZo="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.js"
|
||||
>
|
||||
</script>
|
||||
|
||||
|
||||
<!-- self-hosted -->
|
||||
<script src="/js/prism.js"></script>
|
||||
<script src="/js/build/app.js"></script>
|
||||
|
||||
{% if google_analytics %}
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="{{ gtag }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '{{ google_analytics }}');
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Storing client location ...</title>
|
||||
</head>
|
||||
<body>
|
||||
<script language="javascript">
|
||||
if (document.location.hash != null && document.location.hash != "") {
|
||||
localStorage.setItem("login-url-hash", document.location.hash)
|
||||
}
|
||||
window.location.href = document.location.pathname + "?redirect=1"
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Restoring client location ...</title>
|
||||
</head>
|
||||
<body>
|
||||
<script language="javascript">
|
||||
// /login/authorized
|
||||
hash = localStorage.getItem("login-url-hash")
|
||||
if (null != hash) {
|
||||
localStorage.removeItem("login-url-hash")
|
||||
location.href = "/" + hash
|
||||
}
|
||||
else {
|
||||
location.href = "/"
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import gevent
|
||||
import json_delta
|
||||
import requests.exceptions
|
||||
|
||||
from .backoff import expo, random_jitter
|
||||
from .utils import get_short_error_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from requests.exceptions import ConnectionError, RequestException
|
||||
|
||||
|
||||
class Attrs:
|
||||
|
||||
@classmethod
|
||||
def call(cls, f, **kwargs):
|
||||
return f(cls(**kwargs))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.attrs = kwargs
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return self.attrs.get(attr)
|
||||
|
||||
|
||||
def get_short_error_message(e: Exception):
|
||||
'''Generate a reasonable short message why the HTTP request failed'''
|
||||
|
||||
if isinstance(e, RequestException) and e.response is not None:
|
||||
# e.g. "401 Unauthorized"
|
||||
return '{} {}'.format(e.response.status_code, e.response.reason)
|
||||
elif isinstance(e, ConnectionError):
|
||||
# e.g. "ConnectionError" or "ConnectTimeout"
|
||||
return e.__class__.__name__
|
||||
else:
|
||||
return str(e)
|
||||
|
||||
|
||||
def identity(value, *_, **__):
|
||||
"""
|
||||
Trivial identity function: return the value passed in its first argument.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> identity(42)
|
||||
42
|
||||
|
||||
>>> list(
|
||||
... filter(
|
||||
... identity,
|
||||
... [None, False, True, 0, 1, list(), set(), dict()],
|
||||
... ),
|
||||
... )
|
||||
[True, 1]
|
||||
"""
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def const(value, *_, **__):
|
||||
"""
|
||||
Given a value, returns a function that simply returns that value.
|
||||
|
||||
Example:
|
||||
>>> f = const(42)
|
||||
>>> f()
|
||||
42
|
||||
>>> f()
|
||||
42
|
||||
"""
|
||||
|
||||
return lambda *_, **__: value
|
||||
|
||||
|
||||
def catching(computation, catcher=identity, exception=Exception):
|
||||
"""
|
||||
Catch exceptions.
|
||||
|
||||
Call the provided computation with no arguments. If it throws an exception
|
||||
of the provided exception class (or any exception, if no class is provided),
|
||||
return the result of calling the catcher function with the exception as the
|
||||
sole argument. If no catcher function is specified, return the exception.
|
||||
|
||||
Examples:
|
||||
|
||||
Catch a KeyError and return the exception itself:
|
||||
>>> catching(lambda: {'foo': 'bar'}['meh'])
|
||||
KeyError('meh',)
|
||||
|
||||
Catch a KeyError and return a default value:
|
||||
>>> catching(
|
||||
... computation=lambda: {'foo': 'bar'}['meh'],
|
||||
... catcher=const('nope'),
|
||||
... )
|
||||
'nope'
|
||||
"""
|
||||
|
||||
try:
|
||||
return computation()
|
||||
except exception as e:
|
||||
return catcher(e)
|
||||
|
||||
|
||||
def defaulting(computation, default=None, exception=Exception):
|
||||
"""
|
||||
Like `catching`, but just return a default value if an exception is caught.
|
||||
|
||||
If no default value is supplied, default to None.
|
||||
|
||||
Examples:
|
||||
|
||||
Catch a KeyError and return a default value, like the `get` method:
|
||||
>>> defaulting(lambda: {'foo': 'bar'}['meh'], 'nope')
|
||||
'nope'
|
||||
|
||||
Turn a ZeroDivisionError into None:
|
||||
>>> defaulting(lambda: 1/0) == None
|
||||
True
|
||||
"""
|
||||
|
||||
return catching(
|
||||
computation=computation,
|
||||
catcher=const(default),
|
||||
exception=exception,
|
||||
)
|
||||
|
||||
|
||||
def these(what, where=None):
|
||||
"""
|
||||
Combinator for yielding multiple values with property access.
|
||||
|
||||
Yields from the values generated by an attribute of the given object, or
|
||||
the values generated by the given object itself if no attribute key is
|
||||
specified.
|
||||
|
||||
Examples:
|
||||
|
||||
No attribute key specified; yields from the given object:
|
||||
>>> these(['foo', 'bar'])
|
||||
['foo', 'bar']
|
||||
|
||||
An attribute key is specified; yields from the values generated by the
|
||||
specified attribute's value:
|
||||
object:
|
||||
>>> these({'foo': ['bar', 'baz']}, 'foo')
|
||||
['bar', 'baz']
|
||||
|
||||
An invalid attribute key is specified; yields nothing:
|
||||
>>> these({'foo': ['bar', 'baz']}, 'meh')
|
||||
[]
|
||||
"""
|
||||
|
||||
if not where:
|
||||
return what
|
||||
return what[where] if where in what else []
|
||||
Reference in New Issue
Block a user