diff --git a/auth/MANIFEST.in b/auth/MANIFEST.in index 16688ad841a..fe8d7b8c4df 100644 --- a/auth/MANIFEST.in +++ b/auth/MANIFEST.in @@ -1 +1,2 @@ recursive-include auth/templates * +recursive-include auth/static * diff --git a/auth/auth/auth.py b/auth/auth/auth.py index a5447aabd74..d176a719a20 100644 --- a/auth/auth/auth.py +++ b/auth/auth/auth.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse import aiohttp_session +import jinja2 import kubernetes_asyncio.client import kubernetes_asyncio.client.rest import kubernetes_asyncio.config @@ -67,6 +68,8 @@ CLOUD = get_global_config()['cloud'] DEFAULT_NAMESPACE = os.environ['HAIL_DEFAULT_NAMESPACE'] +INACTIVE_USER_TIMEOUT_DAYS = int(os.environ.get('INACTIVE_USER_TIMEOUT_DAYS', '60')) +AUTH_ROOT = os.path.dirname(os.path.abspath(__file__)) is_test_deployment = DEFAULT_NAMESPACE != 'default' @@ -219,9 +222,7 @@ def validate_next_page_url(next_page): actual_next_page_domain = urlparse(next_page).netloc if actual_next_page_domain not in valid_next_domains: - raise web.HTTPBadRequest( - text=f'Invalid next page: \'{next_page}\'. Domain \'{actual_next_page_domain}\' not in {valid_next_domains}' - ) + raise web.HTTPBadRequest(text='Invalid next page.') @routes.get('/healthcheck') @@ -243,6 +244,22 @@ async def openapi(request): return await render_template('auth', request, None, 'openapi.yaml', page_context) +@routes.get('/auth/static/js/{filename}') +@web_security_headers +async def fetch_js_file(request): + filename = request.match_info['filename'] + if filename.endswith('.js'): + page_context = {'base_path': deploy_config.base_path('auth')} + try: + response = await render_template('auth', request, None, f'js/{filename}', page_context) + response.content_type = 'application/javascript' + return response + except jinja2.exceptions.TemplateNotFound as error: + raise web.HTTPNotFound() from error + else: + raise web.HTTPNotFound() + + @routes.get('') @routes.get('/') @web_security_headers @@ -271,7 +288,12 @@ async def creating_account(request: web.Request, userdata: Optional[UserData]) - set_message(session, f'Account does not exist for login id {login_id}.', 'error') raise web.HTTPFound(deploy_config.external_url('auth', '')) - page_context = {'username': user['username'], 'state': user['state'], 'login_id': user['login_id']} + page_context = { + 'username': user['username'], + 'state': user['state'], + 'login_id': user['login_id'], + 'inactive_timeout_days': INACTIVE_USER_TIMEOUT_DAYS, + } if user['state'] in ('deleting', 'deleted'): return await render_template('auth', request, userdata, 'account-error.html', page_context) @@ -422,9 +444,16 @@ async def callback(request) -> web.Response: raise web.HTTPFound(creating_url) - if user['state'] in ('deleting', 'deleted'): - page_context = {'username': user['username'], 'state': user['state'], 'login_id': user['login_id']} - return await render_template('auth', request, user, 'account-error.html', page_context) + if user['state'] in ('deleting', 'deleted', 'inactive'): + page_context = { + 'username': user['username'], + 'state': user['state'], + 'login_id': user['login_id'], + 'inactive_timeout_days': INACTIVE_USER_TIMEOUT_DAYS, + } + return await render_template( + 'auth', request, user, 'account-error.html', page_context, status_code=web.HTTPUnauthorized.status_code + ) if user['state'] == 'creating': if caller == 'signup': @@ -1239,7 +1268,7 @@ def run(): ] ) - setup_aiohttp_jinja2(app, 'auth') + setup_aiohttp_jinja2(app, 'auth', jinja2.FileSystemLoader(f'{AUTH_ROOT}/static/')) setup_aiohttp_session(app) setup_common_static_routes(routes) diff --git a/auth/auth/static/js/account-creating.js b/auth/auth/static/js/account-creating.js new file mode 100644 index 00000000000..ea518d93414 --- /dev/null +++ b/auth/auth/static/js/account-creating.js @@ -0,0 +1,5 @@ +let protocol = location.protocol.replace("http", "ws") +let sock = new WebSocket(protocol + location.host + "{{ base_path }}/creating/wait"); +sock.onmessage = function (_event) { + window.location.reload() +} diff --git a/auth/auth/static/js/roles.js b/auth/auth/static/js/roles.js new file mode 100644 index 00000000000..36a5cd63577 --- /dev/null +++ b/auth/auth/static/js/roles.js @@ -0,0 +1,73 @@ +const csrfToken = document.head.querySelector('meta[name="csrf"]')?.getAttribute('value') || ''; + +document.getElementsByName('remove-role-button').forEach(button => { + button.addEventListener('click', (_e) => { removeRole(button.dataset.user, button.dataset.role); }) +}); + +document.getElementsByName('add-role-button').forEach(button => { + button.addEventListener('click', (_e) => { addRole(button.dataset['username-input-id'], button.dataset.role); }) +}); + +async function removeRole(username, roleName) { + if (!confirm(`Are you sure you want to remove the '${roleName}' role from user '${username}'?`)) { + return; + } + + try { + const response = await fetch("{{ base_path }}/api/v1alpha/system_roles/${username}", { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + role_removal: roleName + }) + }); + + if (response.ok) { + // Refresh the page to show updated data + window.location.reload(); + } else { + const errorData = await response.json().catch(() => ({})); + alert(`Failed to remove role: ${errorData.message || response.statusText}`); + } + } catch (error) { + console.error('Error removing role:', error); + alert('Failed to remove role. Please try again.'); + } +} + +async function addRole(usernameInputId, roleName) { + const username = document.getElementById(usernameInputId).value.trim(); + + if (!username) { + alert('Please enter a username'); + return; + } + + try { + const response = await fetch("{{ base_path }}/api/v1alpha/system_roles/${username}", { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + role_addition: roleName + }) + }); + + if (response.ok) { + // Clear the input and refresh the page + document.getElementById(usernameInputId).value = ''; + window.location.reload(); + } else { + const errorData = await response.json().catch(() => ({})); + alert(`Failed to add role: ${errorData.message || response.statusText}`); + } + } catch (error) { + console.error('Error adding role:', error); + alert('Failed to add role. Please try again.'); + } +} diff --git a/auth/auth/static/js/user.js b/auth/auth/static/js/user.js new file mode 100644 index 00000000000..2d9298b9d1a --- /dev/null +++ b/auth/auth/static/js/user.js @@ -0,0 +1,23 @@ +const tosCheckbox = document.getElementById('tosCheckbox'); +tosCheckbox.addEventListener('load', (_e) => { updateButtons(); }) +tosCheckbox.addEventListener('change', (_e) => { updateButtons(); }) + +function updateButtons() { + const checkbox = document.getElementById('tosCheckbox'); + const signupButton = document.getElementById('signupButton'); + const loginButton = document.getElementById('loginButton'); + + signupButton.disabled = !checkbox.checked; + loginButton.disabled = !checkbox.checked; + + // Update button styles based on state + [signupButton, loginButton].forEach(button => { + if (button.disabled) { + button.classList.add('opacity-50', 'cursor-not-allowed'); + button.classList.remove('hover:bg-blue-700'); + } else { + button.classList.remove('opacity-50', 'cursor-not-allowed'); + button.classList.add('hover:bg-blue-700'); + } + }); +} diff --git a/auth/auth/static/js/users.js b/auth/auth/static/js/users.js new file mode 100644 index 00000000000..ebee6818611 --- /dev/null +++ b/auth/auth/static/js/users.js @@ -0,0 +1,35 @@ +const showActiveCheckbox = document.getElementById('show-active'); +showActiveCheckbox.addEventListener('change', (_e) => { filterTable(); }) +const showDevelopersCheckbox = document.getElementById('show-developers'); +showDevelopersCheckbox.addEventListener('change', (_e) => { filterTable(); }) + +const reactivateUserForm = document.getElementById('reactivate-user-form'); +reactivateUserForm.addEventListener('submit', (_e) => { validateReactivateForm(); }) + +function filterTable() { + const showActive = document.getElementById('show-active').checked; + const showDevelopers = document.getElementById('show-developers').checked; + + const rows = document.querySelectorAll('#users-table tbody tr'); + + rows.forEach(row => { + const state = row.querySelector('[data-state]').dataset.state.trim(); + const isDeveloper = row.querySelector('[data-developer]').dataset.developer.trim(); + + let show = true; + if (showActive && state !== 'active') show = false; + if (showDevelopers && isDeveloper !== '1') show = false; + + row.style.display = show ? '' : 'none'; + }); +} +function validateReactivateForm() { + const userId = document.getElementById('inactive-id').value.trim(); + const username = document.getElementById('inactive-username').value.trim(); + + if (!userId && !username) { + alert('Please provide either a User ID or a Username.'); + return false; + } + return true; +} diff --git a/auth/auth/templates/account-creating.html b/auth/auth/templates/account-creating.html index e4fad1f0d4a..45dd8308aa7 100644 --- a/auth/auth/templates/account-creating.html +++ b/auth/auth/templates/account-creating.html @@ -5,11 +5,7 @@
Creating account for {{ username }} - {% endblock %} diff --git a/auth/auth/templates/account-error.html b/auth/auth/templates/account-error.html index b7845aaa6e4..e9e53ea4a1e 100644 --- a/auth/auth/templates/account-error.html +++ b/auth/auth/templates/account-error.html @@ -2,14 +2,33 @@ {% block title %}Account Error{% endblock %} {% block content %}
-
Account is in a bad state!
+{% if state == 'inactive' %} +
Your account is inactive

- Please contact hail-team@broadinstitute.org + Accounts that haven't been logged into for {{ inactive_timeout_days }} days are automatically marked as inactive. +
+ {% if support_email %} + Contact {{ support_email }} to reactivate. + {% else %} + Contact contact your support team to reactivate. + {% endif %} +
+{% else %} +
Account is in a bad state!
+
+
+ {% if support_email %} + Please contact {{ support_email }} to get help. + {% else %} + Please contact your support team to get help. + {% endif %}
-
Username: {{ username }}
-
Login ID: {{ login_id }}
State: {{ state }}
+{% endif %} +
+
Username: {{ username }}
+
Login ID: {{ login_id }}
{% endblock %} diff --git a/auth/auth/templates/roles.html b/auth/auth/templates/roles.html index 676cb81664c..da460aa4695 100644 --- a/auth/auth/templates/roles.html +++ b/auth/auth/templates/roles.html @@ -1,8 +1,11 @@ {% extends "layout.html" %} {% block title %}Roles{% endblock %} +{% block head %} + +{% endblock %} {% block content %} -{% if all_system_role_assignments.keys().__len__() > 0 %} +{% if all_system_role_assignments.keys().__len__() >= 0 %}

System role assignments

@@ -28,8 +31,10 @@

System role assignments

-{% endif %} - - + +{% endif %} {% endblock %} diff --git a/auth/auth/templates/user.html b/auth/auth/templates/user.html index cb0d98a911f..d1222c71f17 100644 --- a/auth/auth/templates/user.html +++ b/auth/auth/templates/user.html @@ -32,10 +32,10 @@

Log in to continue

{% else %}

You must sign up or log in to continue

{% endif %} - +
- + {% endif %}

diff --git a/auth/auth/templates/users.html b/auth/auth/templates/users.html index 88fc7e7383e..7e2baf60006 100644 --- a/auth/auth/templates/users.html +++ b/auth/auth/templates/users.html @@ -37,7 +37,7 @@

Create User

Reactivate User

-
+
User ID @@ -93,11 +93,11 @@

Invalidate All User Sessions

Users

@@ -130,33 +130,5 @@

Users

- + {% endblock %} diff --git a/auth/deployment.yaml b/auth/deployment.yaml index 955c5b63fac..a8460358657 100644 --- a/auth/deployment.yaml +++ b/auth/deployment.yaml @@ -198,6 +198,12 @@ spec: key: cloud - name: HAIL_SHA value: "{{ code.sha }}" + - name: INACTIVE_USER_TIMEOUT_DAYS + valueFrom: + secretKeyRef: + name: auth-config + key: inactive_user_timeout_days + optional: true {% if scope != "test" %} - name: HAIL_SHOULD_PROFILE value: "1" diff --git a/batch/batch/batch_configuration.py b/batch/batch/batch_configuration.py index b1d1e77e373..d153d15c53d 100644 --- a/batch/batch/batch_configuration.py +++ b/batch/batch/batch_configuration.py @@ -8,6 +8,7 @@ CLOUD = os.environ['CLOUD'] DOCKER_ROOT_IMAGE = os.environ['HAIL_DOCKER_ROOT_IMAGE'] DOCKER_PREFIX = os.environ['HAIL_DOCKER_PREFIX'] +DOCKERHUB_PREFIX = os.environ.get('HAIL_DOCKERHUB_PREFIX', '') KUBERNETES_SERVER_URL = os.environ['KUBERNETES_SERVER_URL'] INTERNAL_GATEWAY_IP = os.environ['INTERNAL_GATEWAY_IP'] diff --git a/batch/batch/cloud/gcp/driver/create_instance.py b/batch/batch/cloud/gcp/driver/create_instance.py index 1580b95ab94..b18c7da6082 100644 --- a/batch/batch/cloud/gcp/driver/create_instance.py +++ b/batch/batch/cloud/gcp/driver/create_instance.py @@ -129,7 +129,7 @@ def scheduling() -> dict: 'network': 'global/networks/default', 'subnetwork': f'regions/{region}/subnetworks/default', 'networkTier': 'PREMIUM', - 'accessConfigs': [{'type': 'ONE_TO_ONE_NAT', 'name': 'external-nat'}], + 'stackType': 'IPV4_ONLY', } ], 'scheduling': scheduling(), diff --git a/batch/batch/driver/instance_collection/job_private.py b/batch/batch/driver/instance_collection/job_private.py index c33fe5c9d8f..7164761af94 100644 --- a/batch/batch/driver/instance_collection/job_private.py +++ b/batch/batch/driver/instance_collection/job_private.py @@ -199,19 +199,21 @@ async def schedule_jobs_loop_body(self): batch_id = record['batch_id'] job_id = record['job_id'] instance_name = record['instance_name'] - id = (batch_id, job_id) - log.info(f'scheduling job {id}') + log.info(f'scheduling job {(batch_id, job_id)}') instance = self.name_instance[instance_name] n_records_seen += 1 - async def schedule_with_error_handling(app, record, id, instance): + async def schedule_with_error_handling(app, record, instance): try: await schedule_job(app, record, instance) except Exception: - log.info(f'scheduling job {id} on {instance} for {self}', exc_info=True) + log.info( + f'scheduling job {(record["batch_id"], record["job_id"])} on {instance} for {self}', + exc_info=True, + ) - await waitable_pool.call(schedule_with_error_handling, self.app, record, id, instance) + await waitable_pool.call(schedule_with_error_handling, self.app, record, instance) await waitable_pool.wait() @@ -427,13 +429,14 @@ async def user_runnable_jobs(user, remaining) -> AsyncIterator[Dict[str, Any]]: async for record in user_runnable_jobs(user, remaining): batch_id = record['batch_id'] job_id = record['job_id'] - id = (batch_id, job_id) attempt_id = secret_alnum_string(6) record['attempt_id'] = attempt_id job_group_id = record['job_group_id'] n_prior_attempts = record['n_prior_attempts'] n_max_attempts = record['n_max_attempts'] - log.info(f'Job {id}: {n_prior_attempts} prior attempts out of a maximum of {n_max_attempts}') + log.info( + f'Job {(batch_id, job_id)}: {n_prior_attempts} prior attempts out of a maximum of {n_max_attempts}' + ) if n_prior_attempts >= n_max_attempts: await mark_job_errored( @@ -459,10 +462,10 @@ async def user_runnable_jobs(user, remaining) -> AsyncIterator[Dict[str, Any]]: n_user_instances_created += 1 should_wait = False - log.info(f'creating job private instance for job {id}') + log.info(f'creating job private instance for job {(batch_id, job_id)}') async def create_instance_with_error_handling( - batch_id: int, job_id: int, attempt_id: str, job_group_id: int, record: dict, id: Tuple[int, int] + batch_id: int, job_id: int, attempt_id: str, job_group_id: int, record: dict ): try: batch_format_version = BatchFormatVersion(record['format_version']) @@ -493,10 +496,12 @@ async def create_instance_with_error_handling( traceback.format_exc(), ) except Exception: - log.exception(f'while creating job private instance for job {id}', exc_info=True) + log.exception( + f'while creating job private instance for job {(batch_id, job_id)}', exc_info=True + ) await waitable_pool.call( - create_instance_with_error_handling, batch_id, job_id, attempt_id, job_group_id, record, id + create_instance_with_error_handling, batch_id, job_id, attempt_id, job_group_id, record ) remaining.value -= 1 diff --git a/batch/batch/driver/instance_collection/pool.py b/batch/batch/driver/instance_collection/pool.py index a1b5391f373..95e6313fda0 100644 --- a/batch/batch/driver/instance_collection/pool.py +++ b/batch/batch/driver/instance_collection/pool.py @@ -709,7 +709,9 @@ async def user_runnable_jobs(user): n_prior_attempts = record['n_prior_attempts'] n_max_attempts = record['n_max_attempts'] - log.info(f'Job {id}: {n_prior_attempts} prior attempts out of a maximum of {n_max_attempts}') + log.info( + f'Job {(record["batch_id"], record["job_id"])}: {n_prior_attempts} prior attempts out of a maximum of {n_max_attempts}' + ) if n_prior_attempts >= n_max_attempts: await mark_job_errored( diff --git a/batch/batch/driver/main.py b/batch/batch/driver/main.py index 1291c90f727..a66d32ceaae 100644 --- a/batch/batch/driver/main.py +++ b/batch/batch/driver/main.py @@ -93,6 +93,8 @@ log.info(f'REFRESH_INTERVAL_IN_SECONDS {REFRESH_INTERVAL_IN_SECONDS}') +DRIVER_ROOT = os.path.dirname(os.path.abspath(__file__)) + routes = web.RouteTableDef() deploy_config = get_deploy_config() @@ -580,12 +582,13 @@ async def configure_feature_flags(request: web.Request, _) -> NoReturn: compact_billing_tables = 'compact_billing_tables' in post oms_agent = 'oms_agent' in post + dockerhub_proxy = 'dockerhub_proxy' in post await db.execute_update( """ -UPDATE feature_flags SET compact_billing_tables = %s, oms_agent = %s; +UPDATE feature_flags SET compact_billing_tables = %s, oms_agent = %s, dockerhub_proxy = %s; """, - (compact_billing_tables, oms_agent), + (compact_billing_tables, oms_agent, dockerhub_proxy), ) row = await db.select_and_fetchone('SELECT * FROM feature_flags') @@ -1792,6 +1795,7 @@ def run(): setup_aiohttp_jinja2(app, 'batch.driver') setup_common_static_routes(routes) + routes.static('/batch_driver/static/js', f'{DRIVER_ROOT}/static/js') app.add_routes(routes) app.router.add_get("/metrics", server_stats) diff --git a/batch/batch/driver/static/js/quotas.js b/batch/batch/driver/static/js/quotas.js new file mode 100644 index 00000000000..6fd91163110 --- /dev/null +++ b/batch/batch/driver/static/js/quotas.js @@ -0,0 +1,6 @@ +if (Object.hasOwn(window, "Plotly")) { + if (document.getElementById('plotly-timeseries') !== undefined) { + var graphJson = JSON.parse(document.getElementById('plotly-timeseries').dataset.graph); + window.Plotly.plot('plotly-timeseries', graphJson, {}); + } +} diff --git a/batch/batch/driver/templates/index.html b/batch/batch/driver/templates/index.html index db10e46cc97..21ca75b794b 100644 --- a/batch/batch/driver/templates/index.html +++ b/batch/batch/driver/templates/index.html @@ -41,6 +41,12 @@

Feature Flags

name="oms_agent" {% if feature_flags['oms_agent'] %}checked{% endif %} value="true" /> + +
diff --git a/batch/batch/driver/templates/quotas.html b/batch/batch/driver/templates/quotas.html index 84fbb1264b2..ee3185d3ed7 100644 --- a/batch/batch/driver/templates/quotas.html +++ b/batch/batch/driver/templates/quotas.html @@ -4,13 +4,10 @@

Quotas by Region

{% if plot_json is not none %} +
-
- -
+ +