|
|
|
|
@ -16,15 +16,11 @@ from flask import (
|
|
|
|
|
Flask,
|
|
|
|
|
Response,
|
|
|
|
|
abort,
|
|
|
|
|
redirect,
|
|
|
|
|
render_template,
|
|
|
|
|
request,
|
|
|
|
|
send_from_directory,
|
|
|
|
|
session,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from flask_oauthlib.client import OAuth
|
|
|
|
|
from functools import wraps
|
|
|
|
|
from gevent import sleep, spawn
|
|
|
|
|
from gevent.pywsgi import WSGIServer
|
|
|
|
|
from jq import jq
|
|
|
|
|
@ -34,11 +30,9 @@ from os import getenv
|
|
|
|
|
from re import X, compile
|
|
|
|
|
from requests.exceptions import RequestException
|
|
|
|
|
from signal import SIGTERM, signal
|
|
|
|
|
from urllib.parse import urljoin
|
|
|
|
|
|
|
|
|
|
from . import __version__
|
|
|
|
|
from .cluster_discovery import DEFAULT_CLUSTERS, StaticClusterDiscoverer
|
|
|
|
|
from .oauth import OAuthRemoteAppWithRefresh
|
|
|
|
|
|
|
|
|
|
from .spiloutils import (
|
|
|
|
|
apply_postgresql,
|
|
|
|
|
@ -71,11 +65,8 @@ logger = getLogger(__name__)
|
|
|
|
|
SERVER_STATUS = {'shutdown': False}
|
|
|
|
|
|
|
|
|
|
APP_URL = getenv('APP_URL')
|
|
|
|
|
AUTHORIZE_URL = getenv('AUTHORIZE_URL')
|
|
|
|
|
SPILO_S3_BACKUP_BUCKET = getenv('SPILO_S3_BACKUP_BUCKET')
|
|
|
|
|
TEAM_SERVICE_URL = getenv('TEAM_SERVICE_URL')
|
|
|
|
|
ACCESS_TOKEN_URL = getenv('ACCESS_TOKEN_URL')
|
|
|
|
|
TOKENINFO_URL = getenv('OAUTH2_TOKEN_INFO_URL')
|
|
|
|
|
|
|
|
|
|
OPERATOR_API_URL = getenv('OPERATOR_API_URL', 'http://postgres-operator')
|
|
|
|
|
OPERATOR_CLUSTER_NAME_LABEL = getenv('OPERATOR_CLUSTER_NAME_LABEL', 'cluster-name')
|
|
|
|
|
@ -184,38 +175,6 @@ class WSGITransferEncodingChunked:
|
|
|
|
|
return self.app(environ, start_response)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
oauth = OAuth(app)
|
|
|
|
|
|
|
|
|
|
auth = OAuthRemoteAppWithRefresh(
|
|
|
|
|
oauth,
|
|
|
|
|
'auth',
|
|
|
|
|
request_token_url=None,
|
|
|
|
|
access_token_method='POST',
|
|
|
|
|
access_token_url=ACCESS_TOKEN_URL,
|
|
|
|
|
authorize_url=AUTHORIZE_URL,
|
|
|
|
|
)
|
|
|
|
|
oauth.remote_apps['auth'] = auth
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_token(token):
|
|
|
|
|
if not token:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
r = requests.get(TOKENINFO_URL, headers={'Authorization': token})
|
|
|
|
|
|
|
|
|
|
return r.status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def authorize(f):
|
|
|
|
|
@wraps(f)
|
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
|
if AUTHORIZE_URL and 'auth_token' not in session:
|
|
|
|
|
return redirect(urljoin(APP_URL, '/login'))
|
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ok(body={}, status=200):
|
|
|
|
|
return (
|
|
|
|
|
Response(
|
|
|
|
|
@ -297,19 +256,16 @@ STATIC_HEADERS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/css/<path:path>')
|
|
|
|
|
@authorize
|
|
|
|
|
def send_css(path):
|
|
|
|
|
return send_from_directory('static/', path), 200, STATIC_HEADERS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/js/<path:path>')
|
|
|
|
|
@authorize
|
|
|
|
|
def send_js(path):
|
|
|
|
|
return send_from_directory('static/', path), 200, STATIC_HEADERS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/')
|
|
|
|
|
@authorize
|
|
|
|
|
def index():
|
|
|
|
|
return render_template('index.html', google_analytics=GOOGLE_ANALYTICS)
|
|
|
|
|
|
|
|
|
|
@ -345,7 +301,6 @@ DEFAULT_UI_CONFIG = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/config')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_config():
|
|
|
|
|
config = DEFAULT_UI_CONFIG.copy()
|
|
|
|
|
config.update(OPERATOR_UI_CONFIG)
|
|
|
|
|
@ -407,17 +362,15 @@ def get_teams_for_user(user_name):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/teams')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_teams():
|
|
|
|
|
return ok(
|
|
|
|
|
get_teams_for_user(
|
|
|
|
|
session.get('user_name', ''),
|
|
|
|
|
request.headers.get('X-Uid', ''),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/services/<namespace>/<cluster>')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_service(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
@ -433,7 +386,6 @@ def get_service(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/pooler/<namespace>/<cluster>')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_list_poolers(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
@ -449,7 +401,6 @@ def get_list_poolers(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/statefulsets/<namespace>/<cluster>')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_list_clusters(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
@ -465,7 +416,6 @@ def get_list_clusters(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/statefulsets/<namespace>/<cluster>/pods')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_list_members(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
@ -485,7 +435,6 @@ def get_list_members(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/namespaces')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_namespaces():
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*']:
|
|
|
|
|
@ -503,7 +452,6 @@ def get_namespaces():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/postgresqls')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_postgresqls():
|
|
|
|
|
postgresqls = [
|
|
|
|
|
{
|
|
|
|
|
@ -602,7 +550,6 @@ def read_only(handler):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/postgresqls/<namespace>/<cluster>', methods=['POST'])
|
|
|
|
|
@authorize
|
|
|
|
|
@namespaced
|
|
|
|
|
def update_postgresql(namespace: str, cluster: str):
|
|
|
|
|
if READ_ONLY_MODE:
|
|
|
|
|
@ -614,8 +561,8 @@ def update_postgresql(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
postgresql = request.get_json(force=True)
|
|
|
|
|
|
|
|
|
|
teams = get_teams_for_user(session.get('user_name', ''))
|
|
|
|
|
logger.info(f'Changes to: {cluster} by {session.get("user_name", "local-user")}/{teams} {postgresql}') # noqa
|
|
|
|
|
teams = get_teams_for_user(request.headers.get('X-Uid', ''))
|
|
|
|
|
logger.info(f'Changes to: {cluster} by {request.headers.get("X-Uid", "local-user")}/{teams} {postgresql}') # noqa
|
|
|
|
|
|
|
|
|
|
if SUPERUSER_TEAM and SUPERUSER_TEAM in teams:
|
|
|
|
|
logger.info(f'Allowing edit due to membership in superuser team {SUPERUSER_TEAM}') # noqa
|
|
|
|
|
@ -810,7 +757,6 @@ def update_postgresql(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/postgresqls/<namespace>/<cluster>', methods=['GET'])
|
|
|
|
|
@authorize
|
|
|
|
|
def get_postgresql(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
@ -826,7 +772,6 @@ def get_postgresql(namespace: str, cluster: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/stored_clusters')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_stored_clusters():
|
|
|
|
|
return respond(
|
|
|
|
|
read_stored_clusters(
|
|
|
|
|
@ -837,7 +782,6 @@ def get_stored_clusters():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/stored_clusters/<pg_cluster>', methods=['GET'])
|
|
|
|
|
@authorize
|
|
|
|
|
def get_versions(pg_cluster: str):
|
|
|
|
|
return respond(
|
|
|
|
|
read_versions(
|
|
|
|
|
@ -850,9 +794,7 @@ def get_versions(pg_cluster: str):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/stored_clusters/<pg_cluster>/<uid>', methods=['GET'])
|
|
|
|
|
@authorize
|
|
|
|
|
def get_basebackups(pg_cluster: str, uid: str):
|
|
|
|
|
return respond(
|
|
|
|
|
read_basebackups(
|
|
|
|
|
@ -867,7 +809,6 @@ def get_basebackups(pg_cluster: str, uid: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/create-cluster', methods=['POST'])
|
|
|
|
|
@authorize
|
|
|
|
|
def create_new_cluster():
|
|
|
|
|
|
|
|
|
|
if READ_ONLY_MODE:
|
|
|
|
|
@ -885,8 +826,8 @@ def create_new_cluster():
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
return wrong_namespace()
|
|
|
|
|
|
|
|
|
|
teams = get_teams_for_user(session.get('user_name', ''))
|
|
|
|
|
logger.info(f'Create cluster by {session.get("user_name", "local-user")}/{teams} {postgresql}') # noqa
|
|
|
|
|
teams = get_teams_for_user(request.headers.get('X-Uid', ''))
|
|
|
|
|
logger.info(f'Create cluster by {request.headers.get("X-Uid", "local-user")}/{teams} {postgresql}') # noqa
|
|
|
|
|
|
|
|
|
|
if SUPERUSER_TEAM and SUPERUSER_TEAM in teams:
|
|
|
|
|
logger.info(f'Allowing create due to membership in superuser team {SUPERUSER_TEAM}') # noqa
|
|
|
|
|
@ -898,7 +839,6 @@ def create_new_cluster():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/postgresqls/<namespace>/<cluster>', methods=['DELETE'])
|
|
|
|
|
@authorize
|
|
|
|
|
def delete_postgresql(namespace: str, cluster: str):
|
|
|
|
|
if TARGET_NAMESPACE not in ['', '*', namespace]:
|
|
|
|
|
return wrong_namespace()
|
|
|
|
|
@ -910,9 +850,9 @@ def delete_postgresql(namespace: str, cluster: str):
|
|
|
|
|
if postgresql is None:
|
|
|
|
|
return not_found()
|
|
|
|
|
|
|
|
|
|
teams = get_teams_for_user(session.get('user_name', ''))
|
|
|
|
|
teams = get_teams_for_user(request.headers.get('X-Uid', ''))
|
|
|
|
|
|
|
|
|
|
logger.info(f'Delete cluster: {cluster} by {session.get("user_name", "local-user")}/{teams}') # noqa
|
|
|
|
|
logger.info(f'Delete cluster: {cluster} by {request.headers.get("X-Uid", "local-user")}/{teams}') # noqa
|
|
|
|
|
|
|
|
|
|
if SUPERUSER_TEAM and SUPERUSER_TEAM in teams:
|
|
|
|
|
logger.info(f'Allowing delete due to membership in superuser team {SUPERUSER_TEAM}') # noqa
|
|
|
|
|
@ -936,78 +876,30 @@ def proxy_operator(url: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/operator/status')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_operator_status():
|
|
|
|
|
return proxy_operator('/status/')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/operator/workers/<worker>/queue')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_operator_get_queue(worker: int):
|
|
|
|
|
return proxy_operator(f'/workers/{worker}/queue')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/operator/workers/<worker>/logs')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_operator_get_logs(worker: int):
|
|
|
|
|
return proxy_operator(f'/workers/{worker}/logs')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/operator/clusters/<namespace>/<cluster>/logs')
|
|
|
|
|
@authorize
|
|
|
|
|
def get_operator_get_logs_per_cluster(namespace: str, cluster: str):
|
|
|
|
|
return proxy_operator(f'/clusters/{namespace}/{cluster}/logs/')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/login')
|
|
|
|
|
def login():
|
|
|
|
|
redirect = request.args.get('redirect', False)
|
|
|
|
|
if not redirect:
|
|
|
|
|
return render_template('login-deeplink.html')
|
|
|
|
|
|
|
|
|
|
redirect_uri = urljoin(APP_URL, '/login/authorized')
|
|
|
|
|
return auth.authorize(callback=redirect_uri)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/logout')
|
|
|
|
|
def logout():
|
|
|
|
|
session.pop('auth_token', None)
|
|
|
|
|
return redirect(urljoin(APP_URL, '/'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/favicon.png')
|
|
|
|
|
def favicon():
|
|
|
|
|
return send_from_directory('static/', 'favicon-96x96.png'), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/login/authorized')
|
|
|
|
|
def authorized():
|
|
|
|
|
resp = auth.authorized_response()
|
|
|
|
|
if resp is None:
|
|
|
|
|
return 'Access denied: reason=%s error=%s' % (
|
|
|
|
|
request.args['error'],
|
|
|
|
|
request.args['error_description']
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not isinstance(resp, dict):
|
|
|
|
|
return 'Invalid auth response'
|
|
|
|
|
|
|
|
|
|
session['auth_token'] = (resp['access_token'], '')
|
|
|
|
|
|
|
|
|
|
r = requests.get(
|
|
|
|
|
TOKENINFO_URL,
|
|
|
|
|
headers={
|
|
|
|
|
'Authorization': f'Bearer {session["auth_token"][0]}',
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
session['user_name'] = r.json().get('uid')
|
|
|
|
|
|
|
|
|
|
logger.info(f'Login from: {session["user_name"]}')
|
|
|
|
|
|
|
|
|
|
# return redirect(urljoin(APP_URL, '/'))
|
|
|
|
|
return render_template('login-resolve-deeplink.html')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def shutdown():
|
|
|
|
|
# just wait some time to give Kubernetes time to update endpoints
|
|
|
|
|
# this requires changing the readinessProbe's
|
|
|
|
|
@ -1083,28 +975,20 @@ def init_cluster():
|
|
|
|
|
help='Verbose logging',
|
|
|
|
|
is_flag=True,
|
|
|
|
|
)
|
|
|
|
|
@option(
|
|
|
|
|
'--secret-key',
|
|
|
|
|
default='development',
|
|
|
|
|
envvar='SECRET_KEY',
|
|
|
|
|
help='Secret key for session cookies',
|
|
|
|
|
)
|
|
|
|
|
@option(
|
|
|
|
|
'--clusters',
|
|
|
|
|
envvar='CLUSTERS',
|
|
|
|
|
help=f'Comma separated list of Kubernetes API server URLs (default: {DEFAULT_CLUSTERS})', # noqa
|
|
|
|
|
type=CommaSeparatedValues(),
|
|
|
|
|
)
|
|
|
|
|
def main(port, secret_key, debug, clusters: list):
|
|
|
|
|
def main(port, debug, clusters: list):
|
|
|
|
|
global TARGET_NAMESPACE
|
|
|
|
|
|
|
|
|
|
basicConfig(stream=sys.stdout, level=(DEBUG if debug else INFO), format='%(asctime)s %(levelname)s: %(message)s',)
|
|
|
|
|
|
|
|
|
|
init_cluster()
|
|
|
|
|
|
|
|
|
|
logger.info(f'Access token URL: {ACCESS_TOKEN_URL}')
|
|
|
|
|
logger.info(f'App URL: {APP_URL}')
|
|
|
|
|
logger.info(f'Authorize URL: {AUTHORIZE_URL}')
|
|
|
|
|
logger.info(f'Operator API URL: {OPERATOR_API_URL}')
|
|
|
|
|
logger.info(f'Operator cluster name label: {OPERATOR_CLUSTER_NAME_LABEL}')
|
|
|
|
|
logger.info(f'Readonly mode: {"enabled" if READ_ONLY_MODE else "disabled"}') # noqa
|
|
|
|
|
@ -1113,7 +997,6 @@ def main(port, secret_key, debug, clusters: list):
|
|
|
|
|
logger.info(f'Superuser team: {SUPERUSER_TEAM}')
|
|
|
|
|
logger.info(f'Target namespace: {TARGET_NAMESPACE}')
|
|
|
|
|
logger.info(f'Teamservice URL: {TEAM_SERVICE_URL}')
|
|
|
|
|
logger.info(f'Tokeninfo URL: {TOKENINFO_URL}')
|
|
|
|
|
logger.info(f'Use AWS instance_profile: {USE_AWS_INSTANCE_PROFILE}')
|
|
|
|
|
logger.info(f'WAL-E S3 endpoint: {WALE_S3_ENDPOINT}')
|
|
|
|
|
logger.info(f'AWS S3 endpoint: {AWS_ENDPOINT}')
|
|
|
|
|
@ -1136,7 +1019,6 @@ def main(port, secret_key, debug, clusters: list):
|
|
|
|
|
logger.info(f'Target namespace set to: {TARGET_NAMESPACE or "*"}')
|
|
|
|
|
|
|
|
|
|
app.debug = debug
|
|
|
|
|
app.secret_key = secret_key
|
|
|
|
|
|
|
|
|
|
signal(SIGTERM, exit_gracefully)
|
|
|
|
|
|
|
|
|
|
|