Switch to Poetry and Black (#254)
* Pipenv -> Poetry * poetry and black
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
# This version is replaced during release process.
|
||||
__version__ = '2017.0.dev1'
|
||||
__version__ = "2017.0.dev1"
|
||||
|
||||
@@ -9,9 +9,10 @@ import tokens
|
||||
from requests.auth import AuthBase
|
||||
|
||||
from pykube import HTTPClient, KubeConfig
|
||||
|
||||
# default URL points to kubectl proxy
|
||||
DEFAULT_CLUSTERS = 'http://localhost:8001/'
|
||||
CLUSTER_ID_INVALID_CHARS = re.compile('[^a-z0-9:-]')
|
||||
DEFAULT_CLUSTERS = "http://localhost:8001/"
|
||||
CLUSTER_ID_INVALID_CHARS = re.compile("[^a-z0-9:-]")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,27 +20,27 @@ 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://'):
|
||||
"""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('-')
|
||||
url = url[len(prefix) :]
|
||||
return CLUSTER_ID_INVALID_CHARS.sub("-", url.lower()).strip("-")
|
||||
|
||||
|
||||
class StaticAuthorizationHeaderAuth(AuthBase):
|
||||
'''Static authentication with given "Authorization" header'''
|
||||
"""Static authentication with given "Authorization" header"""
|
||||
|
||||
def __init__(self, authorization):
|
||||
self.authorization = authorization
|
||||
|
||||
def __call__(self, request):
|
||||
request.headers['Authorization'] = self.authorization
|
||||
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)'''
|
||||
"""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
|
||||
@@ -47,18 +48,12 @@ class OAuthTokenAuth(AuthBase):
|
||||
|
||||
def __call__(self, request):
|
||||
token = tokens.get(self.token_name)
|
||||
request.headers['Authorization'] = f'Bearer {token}'
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
return request
|
||||
|
||||
|
||||
class Cluster:
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
name: str,
|
||||
api_server_url: str,
|
||||
client: HTTPClient
|
||||
):
|
||||
def __init__(self, id: str, name: str, api_server_url: str, client: HTTPClient):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.api_server_url = api_server_url
|
||||
@@ -66,7 +61,6 @@ class Cluster:
|
||||
|
||||
|
||||
class StaticClusterDiscoverer:
|
||||
|
||||
def __init__(self, api_server_urls: list):
|
||||
self._clusters = []
|
||||
|
||||
@@ -79,15 +73,18 @@ class StaticClusterDiscoverer:
|
||||
config = KubeConfig.from_url(DEFAULT_CLUSTERS)
|
||||
client = HTTPClient(config)
|
||||
cluster = Cluster(
|
||||
generate_cluster_id(DEFAULT_CLUSTERS), "cluster", DEFAULT_CLUSTERS, client
|
||||
generate_cluster_id(DEFAULT_CLUSTERS),
|
||||
"cluster",
|
||||
DEFAULT_CLUSTERS,
|
||||
client,
|
||||
)
|
||||
else:
|
||||
client = HTTPClient(config)
|
||||
cluster = Cluster(
|
||||
generate_cluster_id(config.cluster['server']),
|
||||
generate_cluster_id(config.cluster["server"]),
|
||||
"cluster",
|
||||
config.cluster['server'],
|
||||
client
|
||||
config.cluster["server"],
|
||||
client,
|
||||
)
|
||||
self._clusters.append(cluster)
|
||||
else:
|
||||
@@ -95,47 +92,43 @@ class StaticClusterDiscoverer:
|
||||
config = KubeConfig.from_url(api_server_url)
|
||||
client = HTTPClient(config)
|
||||
generated_id = generate_cluster_id(api_server_url)
|
||||
self._clusters.append(Cluster(generated_id, generated_id, api_server_url, client))
|
||||
self._clusters.append(
|
||||
Cluster(generated_id, generated_id, api_server_url, client)
|
||||
)
|
||||
|
||||
def get_clusters(self):
|
||||
return self._clusters
|
||||
|
||||
|
||||
class ClusterRegistryDiscoverer:
|
||||
|
||||
def __init__(self, cluster_registry_url: str, cache_lifetime=60):
|
||||
self._url = cluster_registry_url
|
||||
self._cache_lifetime = cache_lifetime
|
||||
self._last_cache_refresh = 0
|
||||
self._clusters = []
|
||||
self._session = requests.Session()
|
||||
self._session.auth = OAuthTokenAuth('read-only')
|
||||
self._session.auth = OAuthTokenAuth("read-only")
|
||||
|
||||
def refresh(self):
|
||||
try:
|
||||
response = self._session.get(urljoin(self._url, '/kubernetes-clusters'), timeout=10)
|
||||
response = self._session.get(
|
||||
urljoin(self._url, "/kubernetes-clusters"), timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
clusters = []
|
||||
for row in response.json()['items']:
|
||||
for row in response.json()["items"]:
|
||||
# only consider "ready" clusters
|
||||
if row.get("lifecycle_status", "ready") == "ready":
|
||||
config = KubeConfig.from_url(row['api_server_url'])
|
||||
config = KubeConfig.from_url(row["api_server_url"])
|
||||
client = HTTPClient(config)
|
||||
client.session.auth = OAuthTokenAuth("read-only")
|
||||
clusters.append(
|
||||
Cluster(
|
||||
row["id"],
|
||||
row["alias"],
|
||||
row["api_server_url"],
|
||||
client
|
||||
)
|
||||
Cluster(row["id"], row["alias"], row["api_server_url"], client)
|
||||
)
|
||||
self._clusters = clusters
|
||||
self._last_cache_refresh = time.time()
|
||||
except:
|
||||
logger.exception(
|
||||
f"Failed to refresh from cluster registry {self._url}"
|
||||
)
|
||||
logger.exception(f"Failed to refresh from cluster registry {self._url}")
|
||||
|
||||
def get_clusters(self):
|
||||
now = time.time()
|
||||
@@ -145,7 +138,6 @@ class ClusterRegistryDiscoverer:
|
||||
|
||||
|
||||
class KubeconfigDiscoverer:
|
||||
|
||||
def __init__(self, kubeconfig_path: Path, contexts: set):
|
||||
self._path = kubeconfig_path
|
||||
self._contexts = contexts
|
||||
@@ -162,21 +154,17 @@ class KubeconfigDiscoverer:
|
||||
context_config = KubeConfig(config.doc, context)
|
||||
client = HTTPClient(context_config)
|
||||
cluster = Cluster(
|
||||
context,
|
||||
context,
|
||||
context_config.cluster['server'],
|
||||
client
|
||||
context, context, context_config.cluster["server"], client
|
||||
)
|
||||
yield cluster
|
||||
|
||||
|
||||
class MockDiscoverer:
|
||||
|
||||
def get_clusters(self):
|
||||
for i in range(3):
|
||||
yield Cluster(
|
||||
f"mock-cluster-{i}",
|
||||
f"mock-cluster-{i}",
|
||||
api_server_url=f"https://kube-{i}.example.org",
|
||||
client=None
|
||||
client=None,
|
||||
)
|
||||
|
||||
@@ -19,57 +19,67 @@ session = requests.Session()
|
||||
# https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md
|
||||
class NodeMetrics(APIObject):
|
||||
|
||||
version = 'metrics.k8s.io/v1beta1'
|
||||
endpoint = 'nodes'
|
||||
kind = 'NodeMetrics'
|
||||
version = "metrics.k8s.io/v1beta1"
|
||||
endpoint = "nodes"
|
||||
kind = "NodeMetrics"
|
||||
|
||||
|
||||
# https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md
|
||||
class PodMetrics(NamespacedAPIObject):
|
||||
|
||||
version = 'metrics.k8s.io/v1beta1'
|
||||
endpoint = 'pods'
|
||||
kind = 'PodMetrics'
|
||||
version = "metrics.k8s.io/v1beta1"
|
||||
endpoint = "pods"
|
||||
kind = "PodMetrics"
|
||||
|
||||
|
||||
def map_node_status(status: dict):
|
||||
return {
|
||||
'addresses': status.get('addresses'),
|
||||
'capacity': status.get('capacity'),
|
||||
'allocatable': status.get('allocatable')
|
||||
"addresses": status.get("addresses"),
|
||||
"capacity": status.get("capacity"),
|
||||
"allocatable": status.get("allocatable"),
|
||||
}
|
||||
|
||||
|
||||
def map_node(node: dict):
|
||||
return {
|
||||
'name': node['metadata']['name'],
|
||||
'labels': node['metadata']['labels'],
|
||||
'status': map_node_status(node['status']),
|
||||
'pods': {}
|
||||
"name": node["metadata"]["name"],
|
||||
"labels": node["metadata"]["labels"],
|
||||
"status": map_node_status(node["status"]),
|
||||
"pods": {},
|
||||
}
|
||||
|
||||
|
||||
def map_pod(pod: dict):
|
||||
return {
|
||||
'name': pod['metadata']['name'],
|
||||
'namespace': pod['metadata']['namespace'],
|
||||
'labels': pod['metadata'].get('labels', {}),
|
||||
'phase': pod['status'].get('phase'),
|
||||
'startTime': pod['status']['startTime'] if 'startTime' in pod['status'] else '',
|
||||
'containers': []
|
||||
"name": pod["metadata"]["name"],
|
||||
"namespace": pod["metadata"]["namespace"],
|
||||
"labels": pod["metadata"].get("labels", {}),
|
||||
"phase": pod["status"].get("phase"),
|
||||
"startTime": pod["status"]["startTime"] if "startTime" in pod["status"] else "",
|
||||
"containers": [],
|
||||
}
|
||||
|
||||
|
||||
def map_container(cont: dict, pod: dict):
|
||||
obj = {'name': cont['name'], 'image': cont['image'], 'resources': cont['resources']}
|
||||
status = list([s for s in pod.get('status', {}).get('containerStatuses', []) if s['name'] == cont['name']])
|
||||
obj = {"name": cont["name"], "image": cont["image"], "resources": cont["resources"]}
|
||||
status = list(
|
||||
[
|
||||
s
|
||||
for s in pod.get("status", {}).get("containerStatuses", [])
|
||||
if s["name"] == cont["name"]
|
||||
]
|
||||
)
|
||||
if status:
|
||||
obj.update(**status[0])
|
||||
return obj
|
||||
|
||||
|
||||
def parse_time(s: str):
|
||||
return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||
return (
|
||||
datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ")
|
||||
.replace(tzinfo=datetime.timezone.utc)
|
||||
.timestamp()
|
||||
)
|
||||
|
||||
|
||||
def query_kubernetes_cluster(cluster):
|
||||
@@ -80,54 +90,66 @@ def query_kubernetes_cluster(cluster):
|
||||
unassigned_pods = {}
|
||||
for node in Node.objects(cluster.client):
|
||||
obj = map_node(node.obj)
|
||||
nodes[obj['name']] = obj
|
||||
nodes[obj["name"]] = obj
|
||||
now = time.time()
|
||||
for pod in Pod.objects(cluster.client, namespace=pykube.all):
|
||||
obj = map_pod(pod.obj)
|
||||
if 'deletionTimestamp' in pod.metadata:
|
||||
obj['deleted'] = parse_time(pod.metadata['deletionTimestamp'])
|
||||
for cont in pod.obj['spec']['containers']:
|
||||
obj['containers'].append(map_container(cont, pod.obj))
|
||||
if obj['phase'] in ('Succeeded', 'Failed'):
|
||||
if "deletionTimestamp" in pod.metadata:
|
||||
obj["deleted"] = parse_time(pod.metadata["deletionTimestamp"])
|
||||
for cont in pod.obj["spec"]["containers"]:
|
||||
obj["containers"].append(map_container(cont, pod.obj))
|
||||
if obj["phase"] in ("Succeeded", "Failed"):
|
||||
last_termination_time = 0
|
||||
for container in obj['containers']:
|
||||
termination_time = container.get('state', {}).get('terminated', {}).get('finishedAt')
|
||||
for container in obj["containers"]:
|
||||
termination_time = (
|
||||
container.get("state", {}).get("terminated", {}).get("finishedAt")
|
||||
)
|
||||
if termination_time:
|
||||
termination_time = parse_time(termination_time)
|
||||
if termination_time > last_termination_time:
|
||||
last_termination_time = termination_time
|
||||
if (last_termination_time and last_termination_time < now - 3600) or (obj.get('reason') == 'Evicted'):
|
||||
if (last_termination_time and last_termination_time < now - 3600) or (
|
||||
obj.get("reason") == "Evicted"
|
||||
):
|
||||
# the job/pod finished more than an hour ago or if it is evicted by cgroup limits
|
||||
# => filter out
|
||||
continue
|
||||
pods_by_namespace_name[(pod.namespace, pod.name)] = obj
|
||||
pod_key = f'{pod.namespace}/{pod.name}'
|
||||
node_name = pod.obj['spec'].get('nodeName')
|
||||
pod_key = f"{pod.namespace}/{pod.name}"
|
||||
node_name = pod.obj["spec"].get("nodeName")
|
||||
if node_name in nodes:
|
||||
nodes[node_name]['pods'][pod_key] = obj
|
||||
nodes[node_name]["pods"][pod_key] = obj
|
||||
else:
|
||||
unassigned_pods[pod_key] = obj
|
||||
|
||||
try:
|
||||
for node_metrics in NodeMetrics.objects(cluster.client):
|
||||
key = node_metrics.name
|
||||
nodes[key]['usage'] = node_metrics.obj.get('usage', {})
|
||||
nodes[key]["usage"] = node_metrics.obj.get("usage", {})
|
||||
except Exception as e:
|
||||
logger.warning('Failed to query node metrics {}: {}'.format(cluster.id, get_short_error_message(e)))
|
||||
logger.warning(
|
||||
"Failed to query node metrics {}: {}".format(
|
||||
cluster.id, get_short_error_message(e)
|
||||
)
|
||||
)
|
||||
try:
|
||||
for pod_metrics in PodMetrics.objects(cluster.client, namespace=pykube.all):
|
||||
key = (pod_metrics.namespace, pod_metrics.name)
|
||||
pod = pods_by_namespace_name.get(key)
|
||||
if pod:
|
||||
for container in pod['containers']:
|
||||
for container_metrics in pod_metrics.obj.get('containers', []):
|
||||
if container['name'] == container_metrics['name']:
|
||||
container['resources']['usage'] = container_metrics['usage']
|
||||
for container in pod["containers"]:
|
||||
for container_metrics in pod_metrics.obj.get("containers", []):
|
||||
if container["name"] == container_metrics["name"]:
|
||||
container["resources"]["usage"] = container_metrics["usage"]
|
||||
except Exception as e:
|
||||
logger.warning('Failed to query pod metrics for cluster {}: {}'.format(cluster.id, get_short_error_message(e)))
|
||||
logger.warning(
|
||||
"Failed to query pod metrics for cluster {}: {}".format(
|
||||
cluster.id, get_short_error_message(e)
|
||||
)
|
||||
)
|
||||
return {
|
||||
'id': cluster_id,
|
||||
'api_server_url': api_server_url,
|
||||
'nodes': nodes,
|
||||
'unassigned_pods': unassigned_pods
|
||||
"id": cluster_id,
|
||||
"api_server_url": api_server_url,
|
||||
"nodes": nodes,
|
||||
"unassigned_pods": unassigned_pods,
|
||||
}
|
||||
|
||||
@@ -24,25 +24,32 @@ from urllib.parse import urljoin
|
||||
from .mock import query_mock_cluster
|
||||
from .kubernetes import query_kubernetes_cluster
|
||||
from .stores import MemoryStore, RedisStore
|
||||
from .cluster_discovery import DEFAULT_CLUSTERS, StaticClusterDiscoverer, ClusterRegistryDiscoverer, KubeconfigDiscoverer, MockDiscoverer
|
||||
from .cluster_discovery import (
|
||||
DEFAULT_CLUSTERS,
|
||||
StaticClusterDiscoverer,
|
||||
ClusterRegistryDiscoverer,
|
||||
KubeconfigDiscoverer,
|
||||
MockDiscoverer,
|
||||
)
|
||||
from .update import update_clusters
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_STATUS = {'shutdown': False}
|
||||
AUTHORIZE_URL = os.getenv('AUTHORIZE_URL')
|
||||
ACCESS_TOKEN_URL = os.getenv('ACCESS_TOKEN_URL')
|
||||
APP_URL = os.getenv('APP_URL')
|
||||
SCOPE = os.getenv('SCOPE')
|
||||
SERVER_STATUS = {"shutdown": False}
|
||||
AUTHORIZE_URL = os.getenv("AUTHORIZE_URL")
|
||||
ACCESS_TOKEN_URL = os.getenv("ACCESS_TOKEN_URL")
|
||||
APP_URL = os.getenv("APP_URL")
|
||||
SCOPE = os.getenv("SCOPE")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
oauth_blueprint = OAuth2ConsumerBlueprintWithClientRefresh(
|
||||
"oauth", __name__,
|
||||
"oauth",
|
||||
__name__,
|
||||
authorization_url=AUTHORIZE_URL,
|
||||
token_url=ACCESS_TOKEN_URL,
|
||||
scope=SCOPE
|
||||
scope=SCOPE,
|
||||
)
|
||||
app.register_blueprint(oauth_blueprint, url_prefix="/login")
|
||||
|
||||
@@ -50,35 +57,48 @@ app.register_blueprint(oauth_blueprint, url_prefix="/login")
|
||||
def authorize(f):
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if AUTHORIZE_URL and 'auth_token' not in flask.session and not oauth_blueprint.session.authorized:
|
||||
return redirect(url_for('oauth.login'))
|
||||
if (
|
||||
AUTHORIZE_URL
|
||||
and "auth_token" not in flask.session
|
||||
and not oauth_blueprint.session.authorized
|
||||
):
|
||||
return redirect(url_for("oauth.login"))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
@app.route("/health")
|
||||
def health():
|
||||
if SERVER_STATUS['shutdown']:
|
||||
if SERVER_STATUS["shutdown"]:
|
||||
flask.abort(503)
|
||||
else:
|
||||
return 'OK'
|
||||
return "OK"
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@app.route("/")
|
||||
@authorize
|
||||
def index():
|
||||
static_build_path = Path(__file__).parent / 'static' / 'build'
|
||||
candidates = sorted(static_build_path.glob('app*.js'))
|
||||
static_build_path = Path(__file__).parent / "static" / "build"
|
||||
candidates = sorted(static_build_path.glob("app*.js"))
|
||||
if candidates:
|
||||
app_js = candidates[0].name
|
||||
if app.debug:
|
||||
# cache busting for local development
|
||||
app_js += '?_={}'.format(time.time())
|
||||
app_js += "?_={}".format(time.time())
|
||||
else:
|
||||
logger.error('Could not find JavaScript application bundle app*.js in {}'.format(static_build_path))
|
||||
flask.abort(503, 'JavaScript application bundle not found (missing build)')
|
||||
return flask.render_template('index.html', app_js=app_js, version=kube_ops_view.__version__, app_config_json=json.dumps(app.app_config))
|
||||
logger.error(
|
||||
"Could not find JavaScript application bundle app*.js in {}".format(
|
||||
static_build_path
|
||||
)
|
||||
)
|
||||
flask.abort(503, "JavaScript application bundle not found (missing build)")
|
||||
return flask.render_template(
|
||||
"index.html",
|
||||
app_js=app_js,
|
||||
version=kube_ops_view.__version__,
|
||||
app_config_json=json.dumps(app.app_config),
|
||||
)
|
||||
|
||||
|
||||
def event(cluster_ids: set):
|
||||
@@ -89,55 +109,72 @@ def event(cluster_ids: set):
|
||||
if status:
|
||||
# send the cluster status including last_query_time BEFORE the cluster data
|
||||
# so the UI knows how to render correctly from the start
|
||||
yield 'event: clusterstatus\ndata: ' + json.dumps({'cluster_id': cluster_id, 'status': status}, separators=(',', ':')) + '\n\n'
|
||||
yield "event: clusterstatus\ndata: " + json.dumps(
|
||||
{"cluster_id": cluster_id, "status": status}, separators=(",", ":")
|
||||
) + "\n\n"
|
||||
cluster = app.store.get_cluster_data(cluster_id)
|
||||
if cluster:
|
||||
yield 'event: clusterupdate\ndata: ' + json.dumps(cluster, separators=(',', ':')) + '\n\n'
|
||||
yield 'event: bootstrapend\ndata: \n\n'
|
||||
yield "event: clusterupdate\ndata: " + json.dumps(
|
||||
cluster, separators=(",", ":")
|
||||
) + "\n\n"
|
||||
yield "event: bootstrapend\ndata: \n\n"
|
||||
|
||||
while True:
|
||||
for event_type, event_data in app.store.listen():
|
||||
# hacky, event_data can be delta or full cluster object
|
||||
if not cluster_ids or event_data.get('cluster_id', event_data.get('id')) in cluster_ids:
|
||||
yield 'event: ' + event_type + '\ndata: ' + json.dumps(event_data, separators=(',', ':')) + '\n\n'
|
||||
if (
|
||||
not cluster_ids
|
||||
or event_data.get("cluster_id", event_data.get("id")) in cluster_ids
|
||||
):
|
||||
yield "event: " + event_type + "\ndata: " + json.dumps(
|
||||
event_data, separators=(",", ":")
|
||||
) + "\n\n"
|
||||
|
||||
|
||||
@app.route('/events')
|
||||
@app.route("/events")
|
||||
@authorize
|
||||
def get_events():
|
||||
'''SSE (Server Side Events), for an EventSource'''
|
||||
"""SSE (Server Side Events), for an EventSource"""
|
||||
cluster_ids = set()
|
||||
for _id in flask.request.args.get('cluster_ids', '').split():
|
||||
for _id in flask.request.args.get("cluster_ids", "").split():
|
||||
if _id:
|
||||
cluster_ids.add(_id)
|
||||
return flask.Response(event(cluster_ids), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
return flask.Response(
|
||||
event(cluster_ids),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route('/screen-tokens', methods=['GET', 'POST'])
|
||||
@app.route("/screen-tokens", methods=["GET", "POST"])
|
||||
@authorize
|
||||
def screen_tokens():
|
||||
new_token = None
|
||||
if flask.request.method == 'POST':
|
||||
if flask.request.method == "POST":
|
||||
new_token = app.store.create_screen_token()
|
||||
return flask.render_template('screen-tokens.html', new_token=new_token)
|
||||
return flask.render_template("screen-tokens.html", new_token=new_token)
|
||||
|
||||
|
||||
@app.route('/screen/<token>')
|
||||
@app.route("/screen/<token>")
|
||||
def redeem_screen_token(token: str):
|
||||
remote_addr = flask.request.headers.get('X-Forwarded-For') or flask.request.remote_addr
|
||||
logger.info('Trying to redeem screen token "{}" for IP {}..'.format(token, remote_addr))
|
||||
remote_addr = (
|
||||
flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr
|
||||
)
|
||||
logger.info(
|
||||
'Trying to redeem screen token "{}" for IP {}..'.format(token, remote_addr)
|
||||
)
|
||||
try:
|
||||
app.store.redeem_screen_token(token, remote_addr)
|
||||
except:
|
||||
flask.abort(401)
|
||||
flask.session['auth_token'] = (token, '')
|
||||
return redirect(urljoin(APP_URL, '/'))
|
||||
flask.session["auth_token"] = (token, "")
|
||||
return redirect(urljoin(APP_URL, "/"))
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
flask.session.pop('auth_token', None)
|
||||
return redirect(urljoin(APP_URL, '/'))
|
||||
flask.session.pop("auth_token", None)
|
||||
return redirect(urljoin(APP_URL, "/"))
|
||||
|
||||
|
||||
def shutdown():
|
||||
@@ -150,48 +187,120 @@ def shutdown():
|
||||
|
||||
|
||||
def exit_gracefully(signum, frame):
|
||||
logger.info('Received TERM signal, shutting down..')
|
||||
SERVER_STATUS['shutdown'] = True
|
||||
logger.info("Received TERM signal, shutting down..")
|
||||
SERVER_STATUS["shutdown"] = True
|
||||
gevent.spawn(shutdown)
|
||||
|
||||
|
||||
def print_version(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
click.echo('Kubernetes Operational View {}'.format(kube_ops_view.__version__))
|
||||
click.echo("Kubernetes Operational View {}".format(kube_ops_view.__version__))
|
||||
ctx.exit()
|
||||
|
||||
|
||||
class CommaSeparatedValues(click.ParamType):
|
||||
name = 'comma_separated_values'
|
||||
name = "comma_separated_values"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if isinstance(value, str):
|
||||
values = filter(None, value.split(','))
|
||||
values = filter(None, value.split(","))
|
||||
else:
|
||||
values = value
|
||||
return values
|
||||
|
||||
|
||||
@click.command(context_settings={'help_option_names': ['-h', '--help']})
|
||||
@click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True,
|
||||
help='Print the current version number and exit.')
|
||||
@click.option('-p', '--port', type=int, help='HTTP port to listen on (default: 8080)', envvar='SERVER_PORT', default=8080)
|
||||
@click.option('-d', '--debug', is_flag=True, help='Run in debugging mode', envvar='DEBUG')
|
||||
@click.option('-m', '--mock', is_flag=True, help='Mock Kubernetes clusters', envvar='MOCK')
|
||||
@click.option('--secret-key', help='Secret key for session cookies', envvar='SECRET_KEY', default='development')
|
||||
@click.option('--redis-url', help='Redis URL to use for pub/sub and job locking', envvar='REDIS_URL')
|
||||
@click.option('--clusters', type=CommaSeparatedValues(),
|
||||
help='Comma separated list of Kubernetes API server URLs (default: {})'.format(DEFAULT_CLUSTERS), envvar='CLUSTERS')
|
||||
@click.option('--cluster-registry-url', help='URL to cluster registry', envvar='CLUSTER_REGISTRY_URL')
|
||||
@click.option('--kubeconfig-path', type=click.Path(exists=True), help='Path to kubeconfig file', envvar='KUBECONFIG_PATH')
|
||||
@click.option('--kubeconfig-contexts', type=CommaSeparatedValues(),
|
||||
help='List of kubeconfig contexts to use (default: use all defined contexts)', envvar='KUBECONFIG_CONTEXTS')
|
||||
@click.option('--query-interval', type=float, help='Interval in seconds for querying clusters (default: 5)', envvar='QUERY_INTERVAL', default=5)
|
||||
@click.option('--node-link-url-template', help='Template for target URL when clicking on a Node', envvar='NODE_LINK_URL_TEMPLATE')
|
||||
@click.option('--pod-link-url-template', help='Template for target URL when clicking on a Pod', envvar='POD_LINK_URL_TEMPLATE')
|
||||
def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_registry_url, kubeconfig_path, kubeconfig_contexts: list, query_interval,
|
||||
node_link_url_template: str, pod_link_url_template: str):
|
||||
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.option(
|
||||
"-V",
|
||||
"--version",
|
||||
is_flag=True,
|
||||
callback=print_version,
|
||||
expose_value=False,
|
||||
is_eager=True,
|
||||
help="Print the current version number and exit.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--port",
|
||||
type=int,
|
||||
help="HTTP port to listen on (default: 8080)",
|
||||
envvar="SERVER_PORT",
|
||||
default=8080,
|
||||
)
|
||||
@click.option(
|
||||
"-d", "--debug", is_flag=True, help="Run in debugging mode", envvar="DEBUG"
|
||||
)
|
||||
@click.option(
|
||||
"-m", "--mock", is_flag=True, help="Mock Kubernetes clusters", envvar="MOCK"
|
||||
)
|
||||
@click.option(
|
||||
"--secret-key",
|
||||
help="Secret key for session cookies",
|
||||
envvar="SECRET_KEY",
|
||||
default="development",
|
||||
)
|
||||
@click.option(
|
||||
"--redis-url",
|
||||
help="Redis URL to use for pub/sub and job locking",
|
||||
envvar="REDIS_URL",
|
||||
)
|
||||
@click.option(
|
||||
"--clusters",
|
||||
type=CommaSeparatedValues(),
|
||||
help="Comma separated list of Kubernetes API server URLs (default: {})".format(
|
||||
DEFAULT_CLUSTERS
|
||||
),
|
||||
envvar="CLUSTERS",
|
||||
)
|
||||
@click.option(
|
||||
"--cluster-registry-url",
|
||||
help="URL to cluster registry",
|
||||
envvar="CLUSTER_REGISTRY_URL",
|
||||
)
|
||||
@click.option(
|
||||
"--kubeconfig-path",
|
||||
type=click.Path(exists=True),
|
||||
help="Path to kubeconfig file",
|
||||
envvar="KUBECONFIG_PATH",
|
||||
)
|
||||
@click.option(
|
||||
"--kubeconfig-contexts",
|
||||
type=CommaSeparatedValues(),
|
||||
help="List of kubeconfig contexts to use (default: use all defined contexts)",
|
||||
envvar="KUBECONFIG_CONTEXTS",
|
||||
)
|
||||
@click.option(
|
||||
"--query-interval",
|
||||
type=float,
|
||||
help="Interval in seconds for querying clusters (default: 5)",
|
||||
envvar="QUERY_INTERVAL",
|
||||
default=5,
|
||||
)
|
||||
@click.option(
|
||||
"--node-link-url-template",
|
||||
help="Template for target URL when clicking on a Node",
|
||||
envvar="NODE_LINK_URL_TEMPLATE",
|
||||
)
|
||||
@click.option(
|
||||
"--pod-link-url-template",
|
||||
help="Template for target URL when clicking on a Pod",
|
||||
envvar="POD_LINK_URL_TEMPLATE",
|
||||
)
|
||||
def main(
|
||||
port,
|
||||
debug,
|
||||
mock,
|
||||
secret_key,
|
||||
redis_url,
|
||||
clusters: list,
|
||||
cluster_registry_url,
|
||||
kubeconfig_path,
|
||||
kubeconfig_contexts: list,
|
||||
query_interval,
|
||||
node_link_url_template: str,
|
||||
pod_link_url_template: str,
|
||||
):
|
||||
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
||||
|
||||
store = RedisStore(redis_url) if redis_url else MemoryStore()
|
||||
@@ -199,7 +308,10 @@ def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_regis
|
||||
app.debug = debug
|
||||
app.secret_key = secret_key
|
||||
app.store = store
|
||||
app.app_config = {'node_link_url_template': node_link_url_template, 'pod_link_url_template': pod_link_url_template}
|
||||
app.app_config = {
|
||||
"node_link_url_template": node_link_url_template,
|
||||
"pod_link_url_template": pod_link_url_template,
|
||||
}
|
||||
|
||||
if mock:
|
||||
cluster_query = query_mock_cluster
|
||||
@@ -209,14 +321,23 @@ def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_regis
|
||||
if cluster_registry_url:
|
||||
discoverer = ClusterRegistryDiscoverer(cluster_registry_url)
|
||||
elif kubeconfig_path:
|
||||
discoverer = KubeconfigDiscoverer(Path(kubeconfig_path), set(kubeconfig_contexts or []))
|
||||
discoverer = KubeconfigDiscoverer(
|
||||
Path(kubeconfig_path), set(kubeconfig_contexts or [])
|
||||
)
|
||||
else:
|
||||
api_server_urls = clusters or []
|
||||
discoverer = StaticClusterDiscoverer(api_server_urls)
|
||||
|
||||
gevent.spawn(update_clusters, cluster_discoverer=discoverer, query_cluster=cluster_query, store=store, query_interval=query_interval, debug=debug)
|
||||
gevent.spawn(
|
||||
update_clusters,
|
||||
cluster_discoverer=discoverer,
|
||||
query_cluster=cluster_query,
|
||||
store=store,
|
||||
query_interval=query_interval,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
signal.signal(signal.SIGTERM, exit_gracefully)
|
||||
http_server = gevent.pywsgi.WSGIServer(('0.0.0.0', port), app)
|
||||
logger.info('Listening on :{}..'.format(port))
|
||||
http_server = gevent.pywsgi.WSGIServer(("0.0.0.0", port), app)
|
||||
logger.info("Listening on :{}..".format(port))
|
||||
http_server.serve_forever()
|
||||
|
||||
@@ -4,34 +4,33 @@ import string
|
||||
|
||||
|
||||
def hash_int(x: int):
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = ((x >> 16) ^ x) * 0x45D9F3B
|
||||
x = ((x >> 16) ^ x) * 0x45D9F3B
|
||||
x = (x >> 16) ^ x
|
||||
return x
|
||||
|
||||
|
||||
def generate_mock_pod(index: int, i: int, j: int):
|
||||
names = [
|
||||
'agent-cooper',
|
||||
'black-lodge',
|
||||
'bob',
|
||||
'bobby-briggs',
|
||||
'laura-palmer',
|
||||
'leland-palmer',
|
||||
'log-lady',
|
||||
'sheriff-truman',
|
||||
"agent-cooper",
|
||||
"black-lodge",
|
||||
"bob",
|
||||
"bobby-briggs",
|
||||
"laura-palmer",
|
||||
"leland-palmer",
|
||||
"log-lady",
|
||||
"sheriff-truman",
|
||||
]
|
||||
labels = {
|
||||
'env': ['prod', 'dev'],
|
||||
'owner': ['x-wing', 'iris']
|
||||
}
|
||||
pod_phases = ['Pending', 'Running', 'Running', 'Failed']
|
||||
labels = {"env": ["prod", "dev"], "owner": ["x-wing", "iris"]}
|
||||
pod_phases = ["Pending", "Running", "Running", "Failed"]
|
||||
|
||||
pod_labels = {}
|
||||
for li, k in enumerate(labels):
|
||||
v = labels[k]
|
||||
label_choice = hash_int((index + 1) * (i + 1) * (j + 1) * (li + 1)) % (len(v) + 1)
|
||||
if(label_choice != 0):
|
||||
label_choice = hash_int((index + 1) * (i + 1) * (j + 1) * (li + 1)) % (
|
||||
len(v) + 1
|
||||
)
|
||||
if label_choice != 0:
|
||||
pod_labels[k] = v[label_choice - 1]
|
||||
|
||||
phase = pod_phases[hash_int((index + 1) * (i + 1) * (j + 1)) % len(pod_phases)]
|
||||
@@ -44,41 +43,53 @@ def generate_mock_pod(index: int, i: int, j: int):
|
||||
usage_cpu = max(requests_cpu + random.randint(-30, 30), 1)
|
||||
usage_memory = max(requests_memory + random.randint(-64, 128), 1)
|
||||
container = {
|
||||
'name': 'myapp',
|
||||
'image': 'foo/bar/{}'.format(j),
|
||||
'resources': {
|
||||
'requests': {'cpu': f'{requests_cpu}m', 'memory': f'{requests_memory}Mi'},
|
||||
'limits': {},
|
||||
'usage': {'cpu': f'{usage_cpu}m', 'memory': f'{usage_memory}Mi'},
|
||||
"name": "myapp",
|
||||
"image": "foo/bar/{}".format(j),
|
||||
"resources": {
|
||||
"requests": {
|
||||
"cpu": f"{requests_cpu}m",
|
||||
"memory": f"{requests_memory}Mi",
|
||||
},
|
||||
"limits": {},
|
||||
"usage": {"cpu": f"{usage_cpu}m", "memory": f"{usage_memory}Mi"},
|
||||
},
|
||||
'ready': True,
|
||||
'state': {'running': {}}
|
||||
"ready": True,
|
||||
"state": {"running": {}},
|
||||
}
|
||||
if phase == 'Running':
|
||||
if phase == "Running":
|
||||
if j % 13 == 0:
|
||||
container.update(**{'ready': False, 'state': {'waiting': {'reason': 'CrashLoopBackOff'}}})
|
||||
container.update(
|
||||
**{
|
||||
"ready": False,
|
||||
"state": {"waiting": {"reason": "CrashLoopBackOff"}},
|
||||
}
|
||||
)
|
||||
elif j % 7 == 0:
|
||||
container.update(**{'ready': False, 'state': {'running': {}}, 'restartCount': 3})
|
||||
elif phase == 'Failed':
|
||||
del container['state']
|
||||
del container['ready']
|
||||
container.update(
|
||||
**{"ready": False, "state": {"running": {}}, "restartCount": 3}
|
||||
)
|
||||
elif phase == "Failed":
|
||||
del container["state"]
|
||||
del container["ready"]
|
||||
containers.append(container)
|
||||
pod = {
|
||||
'name': '{}-{}-{}'.format(names[hash_int((i + 1) * (j + 1)) % len(names)], i, j),
|
||||
'namespace': 'kube-system' if j < 3 else 'default',
|
||||
'labels': pod_labels,
|
||||
'phase': phase,
|
||||
'containers': containers
|
||||
"name": "{}-{}-{}".format(
|
||||
names[hash_int((i + 1) * (j + 1)) % len(names)], i, j
|
||||
),
|
||||
"namespace": "kube-system" if j < 3 else "default",
|
||||
"labels": pod_labels,
|
||||
"phase": phase,
|
||||
"containers": containers,
|
||||
}
|
||||
if phase == 'Running' and j % 17 == 0:
|
||||
pod['deleted'] = 123
|
||||
if phase == "Running" and j % 17 == 0:
|
||||
pod["deleted"] = 123
|
||||
|
||||
return pod
|
||||
|
||||
|
||||
def query_mock_cluster(cluster):
|
||||
'''Generate deterministic (no randomness!) mock data'''
|
||||
index = int(cluster.id.split('-')[-1])
|
||||
"""Generate deterministic (no randomness!) mock data"""
|
||||
index = int(cluster.id.split("-")[-1])
|
||||
nodes = {}
|
||||
for i in range(10):
|
||||
# add/remove the second to last node every 13 seconds
|
||||
@@ -88,11 +99,11 @@ def query_mock_cluster(cluster):
|
||||
# only the first two clusters have master nodes
|
||||
if i < 2 and index < 2:
|
||||
if index == 0:
|
||||
labels['kubernetes.io/role'] = 'master'
|
||||
labels["kubernetes.io/role"] = "master"
|
||||
elif index == 1:
|
||||
labels['node-role.kubernetes.io/master'] = ''
|
||||
labels["node-role.kubernetes.io/master"] = ""
|
||||
else:
|
||||
labels['master'] = 'true'
|
||||
labels["master"] = "true"
|
||||
pods = {}
|
||||
for j in range(hash_int((index + 1) * (i + 1)) % 32):
|
||||
# add/remove some pods every 7 seconds
|
||||
@@ -100,7 +111,7 @@ def query_mock_cluster(cluster):
|
||||
pass
|
||||
else:
|
||||
pod = generate_mock_pod(index, i, j)
|
||||
pods['{}/{}'.format(pod['namespace'], pod['name'])] = pod
|
||||
pods["{}/{}".format(pod["namespace"], pod["name"])] = pod
|
||||
|
||||
# use data from containers (usage)
|
||||
usage_cpu = 0
|
||||
@@ -111,27 +122,27 @@ def query_mock_cluster(cluster):
|
||||
usage_memory += int(c["resources"]["usage"]["memory"].split("Mi")[0])
|
||||
|
||||
# generate longer name for a node
|
||||
suffix = ''.join(
|
||||
suffix = "".join(
|
||||
[random.choice(string.ascii_letters) for n in range(random.randint(1, 20))]
|
||||
)
|
||||
|
||||
node = {
|
||||
'name': f'node-{i}-{suffix}',
|
||||
'labels': labels,
|
||||
'status': {
|
||||
'capacity': {'cpu': '8', 'memory': '64Gi', 'pods': '110'},
|
||||
'allocatable': {'cpu': '7800m', 'memory': '62Gi'}
|
||||
"name": f"node-{i}-{suffix}",
|
||||
"labels": labels,
|
||||
"status": {
|
||||
"capacity": {"cpu": "8", "memory": "64Gi", "pods": "110"},
|
||||
"allocatable": {"cpu": "7800m", "memory": "62Gi"},
|
||||
},
|
||||
'pods': pods,
|
||||
"pods": pods,
|
||||
# get data from containers (usage)
|
||||
'usage': {'cpu': f'{usage_cpu}m', 'memory': f'{usage_memory}Mi'}
|
||||
"usage": {"cpu": f"{usage_cpu}m", "memory": f"{usage_memory}Mi"},
|
||||
}
|
||||
nodes[node['name']] = node
|
||||
nodes[node["name"]] = node
|
||||
pod = generate_mock_pod(index, 11, index)
|
||||
unassigned_pods = {'{}/{}'.format(pod['namespace'], pod['name']): pod}
|
||||
unassigned_pods = {"{}/{}".format(pod["namespace"], pod["name"]): pod}
|
||||
return {
|
||||
'id': 'mock-cluster-{}'.format(index),
|
||||
'api_server_url': 'https://kube-{}.example.org'.format(index),
|
||||
'nodes': nodes,
|
||||
'unassigned_pods': unassigned_pods
|
||||
"id": "mock-cluster-{}".format(index),
|
||||
"api_server_url": "https://kube-{}.example.org".format(index),
|
||||
"nodes": nodes,
|
||||
"unassigned_pods": unassigned_pods,
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import os
|
||||
from flask_dance.consumer import OAuth2ConsumerBlueprint
|
||||
|
||||
|
||||
CREDENTIALS_DIR = os.getenv('CREDENTIALS_DIR', '')
|
||||
CREDENTIALS_DIR = os.getenv("CREDENTIALS_DIR", "")
|
||||
|
||||
|
||||
class OAuth2ConsumerBlueprintWithClientRefresh(OAuth2ConsumerBlueprint):
|
||||
'''Same as flask_dance.consumer.OAuth2ConsumerBlueprint, but loads client credentials from file'''
|
||||
"""Same as flask_dance.consumer.OAuth2ConsumerBlueprint, but loads client credentials from file"""
|
||||
|
||||
def refresh_credentials(self):
|
||||
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-id')) as fd:
|
||||
with open(os.path.join(CREDENTIALS_DIR, "authcode-client-id")) as fd:
|
||||
# note that we need to set two attributes because of how OAuth2ConsumerBlueprint works :-/
|
||||
self._client_id = self.client_id = fd.read().strip()
|
||||
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-secret')) as fd:
|
||||
with open(os.path.join(CREDENTIALS_DIR, "authcode-client-secret")) as fd:
|
||||
self.client_secret = fd.read().strip()
|
||||
|
||||
def login(self):
|
||||
|
||||
@@ -14,52 +14,55 @@ ONE_YEAR = 3600 * 24 * 365
|
||||
|
||||
|
||||
def generate_token(n: int):
|
||||
'''Generate a random ASCII token of length n'''
|
||||
"""Generate a random ASCII token of length n"""
|
||||
# uses os.urandom()
|
||||
rng = random.SystemRandom()
|
||||
return ''.join([rng.choice(string.ascii_letters + string.digits) for i in range(n)])
|
||||
return "".join([rng.choice(string.ascii_letters + string.digits) for i in range(n)])
|
||||
|
||||
|
||||
def generate_token_data():
|
||||
'''Generate screen token data for storing'''
|
||||
"""Generate screen token data for storing"""
|
||||
token = generate_token(10)
|
||||
now = time.time()
|
||||
return {'token': token, 'created': now, 'expires': now + ONE_YEAR}
|
||||
return {"token": token, "created": now, "expires": now + ONE_YEAR}
|
||||
|
||||
|
||||
def check_token(token: str, remote_addr: str, data: dict):
|
||||
'''Check whether the given screen token is valid, raises exception if not'''
|
||||
"""Check whether the given screen token is valid, raises exception if not"""
|
||||
now = time.time()
|
||||
if data and now < data['expires'] and data.get('remote_addr', remote_addr) == remote_addr:
|
||||
data['remote_addr'] = remote_addr
|
||||
if (
|
||||
data
|
||||
and now < data["expires"]
|
||||
and data.get("remote_addr", remote_addr) == remote_addr
|
||||
):
|
||||
data["remote_addr"] = remote_addr
|
||||
return data
|
||||
else:
|
||||
raise ValueError('Invalid token')
|
||||
raise ValueError("Invalid token")
|
||||
|
||||
|
||||
class AbstractStore:
|
||||
|
||||
def get_cluster_ids(self):
|
||||
return self.get('cluster-ids') or []
|
||||
return self.get("cluster-ids") or []
|
||||
|
||||
def set_cluster_ids(self, cluster_ids: set):
|
||||
self.set('cluster-ids', list(sorted(cluster_ids)))
|
||||
self.set("cluster-ids", list(sorted(cluster_ids)))
|
||||
|
||||
def get_cluster_status(self, cluster_id: str) -> dict:
|
||||
return self.get('clusters:{}:status'.format(cluster_id)) or {}
|
||||
return self.get("clusters:{}:status".format(cluster_id)) or {}
|
||||
|
||||
def set_cluster_status(self, cluster_id: str, status: dict):
|
||||
self.set('clusters:{}:status'.format(cluster_id), status)
|
||||
self.set("clusters:{}:status".format(cluster_id), status)
|
||||
|
||||
def get_cluster_data(self, cluster_id: str) -> dict:
|
||||
return self.get('clusters:{}:data'.format(cluster_id)) or {}
|
||||
return self.get("clusters:{}:data".format(cluster_id)) or {}
|
||||
|
||||
def set_cluster_data(self, cluster_id: str, data: dict):
|
||||
self.set('clusters:{}:data'.format(cluster_id), data)
|
||||
self.set("clusters:{}:data".format(cluster_id), data)
|
||||
|
||||
|
||||
class MemoryStore(AbstractStore):
|
||||
'''Memory-only backend, mostly useful for local debugging'''
|
||||
"""Memory-only backend, mostly useful for local debugging"""
|
||||
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
@@ -74,7 +77,7 @@ class MemoryStore(AbstractStore):
|
||||
|
||||
def acquire_lock(self):
|
||||
# no-op for memory store
|
||||
return 'fake-lock'
|
||||
return "fake-lock"
|
||||
|
||||
def release_lock(self, lock):
|
||||
# no op for memory store
|
||||
@@ -96,7 +99,7 @@ class MemoryStore(AbstractStore):
|
||||
|
||||
def create_screen_token(self):
|
||||
data = generate_token_data()
|
||||
token = data['token']
|
||||
token = data["token"]
|
||||
self._screen_tokens[token] = data
|
||||
return token
|
||||
|
||||
@@ -107,51 +110,54 @@ class MemoryStore(AbstractStore):
|
||||
|
||||
|
||||
class RedisStore(AbstractStore):
|
||||
'''Redis-based backend for deployments with replicas > 1'''
|
||||
"""Redis-based backend for deployments with replicas > 1"""
|
||||
|
||||
def __init__(self, url: str):
|
||||
logger.info('Connecting to Redis on {}..'.format(url))
|
||||
logger.info("Connecting to Redis on {}..".format(url))
|
||||
self._redis = redis.StrictRedis.from_url(url)
|
||||
self._redlock = Redlock([url])
|
||||
|
||||
def set(self, key, value):
|
||||
self._redis.set(key, json.dumps(value, separators=(',', ':')))
|
||||
self._redis.set(key, json.dumps(value, separators=(",", ":")))
|
||||
|
||||
def get(self, key):
|
||||
value = self._redis.get(key)
|
||||
if value:
|
||||
return json.loads(value.decode('utf-8'))
|
||||
return json.loads(value.decode("utf-8"))
|
||||
|
||||
def acquire_lock(self):
|
||||
return self._redlock.lock('update', 10000)
|
||||
return self._redlock.lock("update", 10000)
|
||||
|
||||
def release_lock(self, lock):
|
||||
self._redlock.unlock(lock)
|
||||
|
||||
def publish(self, event_type, event_data):
|
||||
self._redis.publish('default', '{}:{}'.format(event_type, json.dumps(event_data, separators=(',', ':'))))
|
||||
self._redis.publish(
|
||||
"default",
|
||||
"{}:{}".format(event_type, json.dumps(event_data, separators=(",", ":"))),
|
||||
)
|
||||
|
||||
def listen(self):
|
||||
p = self._redis.pubsub()
|
||||
p.subscribe('default')
|
||||
p.subscribe("default")
|
||||
for message in p.listen():
|
||||
if message['type'] == 'message':
|
||||
event_type, data = message['data'].decode('utf-8').split(':', 1)
|
||||
if message["type"] == "message":
|
||||
event_type, data = message["data"].decode("utf-8").split(":", 1)
|
||||
yield (event_type, json.loads(data))
|
||||
|
||||
def create_screen_token(self):
|
||||
'''Generate a new screen token and store it in Redis'''
|
||||
"""Generate a new screen token and store it in Redis"""
|
||||
data = generate_token_data()
|
||||
token = data['token']
|
||||
self._redis.set('screen-tokens:{}'.format(token), json.dumps(data))
|
||||
token = data["token"]
|
||||
self._redis.set("screen-tokens:{}".format(token), json.dumps(data))
|
||||
return token
|
||||
|
||||
def redeem_screen_token(self, token: str, remote_addr: str):
|
||||
'''Validate the given token and bind it to the IP'''
|
||||
redis_key = 'screen-tokens:{}'.format(token)
|
||||
"""Validate the given token and bind it to the IP"""
|
||||
redis_key = "screen-tokens:{}".format(token)
|
||||
data = self._redis.get(redis_key)
|
||||
if not data:
|
||||
raise ValueError('Invalid token')
|
||||
data = json.loads(data.decode('utf-8'))
|
||||
raise ValueError("Invalid token")
|
||||
data = json.loads(data.decode("utf-8"))
|
||||
data = check_token(token, remote_addr, data)
|
||||
self._redis.set(redis_key, json.dumps(data))
|
||||
|
||||
@@ -18,21 +18,30 @@ def calculate_backoff(tries: int):
|
||||
def handle_query_failure(e: Exception, cluster, backoff: dict):
|
||||
if not backoff:
|
||||
backoff = {}
|
||||
tries = backoff.get('tries', 0) + 1
|
||||
backoff['tries'] = tries
|
||||
tries = backoff.get("tries", 0) + 1
|
||||
backoff["tries"] = tries
|
||||
wait_seconds = calculate_backoff(tries)
|
||||
backoff['next_try'] = time.time() + wait_seconds
|
||||
backoff["next_try"] = time.time() + wait_seconds
|
||||
message = get_short_error_message(e)
|
||||
if isinstance(e, requests.exceptions.RequestException):
|
||||
log = logger.error
|
||||
else:
|
||||
log = logger.exception
|
||||
log('Failed to query cluster {} ({}): {} (try {}, wait {} seconds)'.format(
|
||||
cluster.id, cluster.api_server_url, message, tries, round(wait_seconds)))
|
||||
log(
|
||||
"Failed to query cluster {} ({}): {} (try {}, wait {} seconds)".format(
|
||||
cluster.id, cluster.api_server_url, message, tries, round(wait_seconds)
|
||||
)
|
||||
)
|
||||
return backoff
|
||||
|
||||
|
||||
def update_clusters(cluster_discoverer, query_cluster: callable, store, query_interval: float=5, debug: bool=False):
|
||||
def update_clusters(
|
||||
cluster_discoverer,
|
||||
query_cluster: callable,
|
||||
store,
|
||||
query_interval: float = 5,
|
||||
debug: bool = False,
|
||||
):
|
||||
while True:
|
||||
lock = store.acquire_lock()
|
||||
if lock:
|
||||
@@ -43,42 +52,65 @@ def update_clusters(cluster_discoverer, query_cluster: callable, store, query_in
|
||||
cluster_ids.add(cluster.id)
|
||||
status = store.get_cluster_status(cluster.id)
|
||||
now = time.time()
|
||||
if now < status.get('last_query_time', 0) + query_interval:
|
||||
if now < status.get("last_query_time", 0) + query_interval:
|
||||
continue
|
||||
backoff = status.get('backoff')
|
||||
if backoff and now < backoff['next_try']:
|
||||
backoff = status.get("backoff")
|
||||
if backoff and now < backoff["next_try"]:
|
||||
# cluster is still in backoff, skip
|
||||
continue
|
||||
try:
|
||||
logger.debug('Querying cluster {} ({})..'.format(cluster.id, cluster.api_server_url))
|
||||
logger.debug(
|
||||
"Querying cluster {} ({})..".format(
|
||||
cluster.id, cluster.api_server_url
|
||||
)
|
||||
)
|
||||
data = query_cluster(cluster)
|
||||
except Exception as e:
|
||||
backoff = handle_query_failure(e, cluster, backoff)
|
||||
status['backoff'] = backoff
|
||||
store.publish('clusterstatus', {'cluster_id': cluster.id, 'status': status})
|
||||
status["backoff"] = backoff
|
||||
store.publish(
|
||||
"clusterstatus",
|
||||
{"cluster_id": cluster.id, "status": status},
|
||||
)
|
||||
else:
|
||||
status['last_query_time'] = now
|
||||
status["last_query_time"] = now
|
||||
if backoff:
|
||||
logger.info('Cluster {} ({}) recovered after {} tries.'.format(cluster.id, cluster.api_server_url, backoff['tries']))
|
||||
del status['backoff']
|
||||
old_data = store.get_cluster_data(data['id'])
|
||||
logger.info(
|
||||
"Cluster {} ({}) recovered after {} tries.".format(
|
||||
cluster.id, cluster.api_server_url, backoff["tries"]
|
||||
)
|
||||
)
|
||||
del status["backoff"]
|
||||
old_data = store.get_cluster_data(data["id"])
|
||||
if old_data:
|
||||
# https://pikacode.com/phijaro/json_delta/ticket/11/
|
||||
# diff is extremely slow without array_align=False
|
||||
delta = json_delta.diff(old_data, data, verbose=debug, array_align=False)
|
||||
store.publish('clusterdelta', {'cluster_id': cluster.id, 'delta': delta})
|
||||
delta = json_delta.diff(
|
||||
old_data, data, verbose=debug, array_align=False
|
||||
)
|
||||
store.publish(
|
||||
"clusterdelta",
|
||||
{"cluster_id": cluster.id, "delta": delta},
|
||||
)
|
||||
if delta:
|
||||
store.set_cluster_data(cluster.id, data)
|
||||
else:
|
||||
logger.info('Discovered new cluster {} ({}).'.format(cluster.id, cluster.api_server_url))
|
||||
logger.info(
|
||||
"Discovered new cluster {} ({}).".format(
|
||||
cluster.id, cluster.api_server_url
|
||||
)
|
||||
)
|
||||
# first send status with last_query_time!
|
||||
store.publish('clusterstatus', {'cluster_id': cluster.id, 'status': status})
|
||||
store.publish('clusterupdate', data)
|
||||
store.publish(
|
||||
"clusterstatus",
|
||||
{"cluster_id": cluster.id, "status": status},
|
||||
)
|
||||
store.publish("clusterupdate", data)
|
||||
store.set_cluster_data(cluster.id, data)
|
||||
store.set_cluster_status(cluster.id, status)
|
||||
store.set_cluster_ids(cluster_ids)
|
||||
except:
|
||||
logger.exception('Failed to update')
|
||||
logger.exception("Failed to update")
|
||||
finally:
|
||||
store.release_lock(lock)
|
||||
# sleep 1-2 seconds
|
||||
|
||||
@@ -2,11 +2,11 @@ import requests.exceptions
|
||||
|
||||
|
||||
def get_short_error_message(e: Exception):
|
||||
'''Generate a reasonable short message why the HTTP request failed'''
|
||||
"""Generate a reasonable short message why the HTTP request failed"""
|
||||
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
# e.g. "401 Unauthorized"
|
||||
return '{} {}'.format(e.response.status_code, e.response.reason)
|
||||
return "{} {}".format(e.response.status_code, e.response.reason)
|
||||
elif isinstance(e, requests.exceptions.ConnectionError):
|
||||
# e.g. "ConnectionError" or "ConnectTimeout"
|
||||
return e.__class__.__name__
|
||||
|
||||
Reference in New Issue
Block a user