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:
Felix Kunde
2019-07-12 17:02:55 +02:00
committed by GitHub
parent 4fc5822b24
commit 4eabc8f5db
60 changed files with 12752 additions and 13 deletions
+2
View File
@@ -0,0 +1,2 @@
# This version is replaced during release process.
__version__ = '2017.0.dev1'
+3
View File
@@ -0,0 +1,3 @@
from .main import main
main()
+48
View File
@@ -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)
+125
View File
@@ -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
+12
View File
@@ -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"}]
+32
View File
@@ -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
+312
View File
@@ -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

+139
View File
@@ -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
+66
View File
@@ -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;
}
+187
View File
@@ -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>
+12
View File
@@ -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__)
+146
View File
@@ -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 []