2023-08-01 01:15:09 +03:00
|
|
|
# Copyright 2023 Google Inc.
|
|
|
|
#
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License")
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
#
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
#
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
# limitations under the License.
|
|
|
|
|
2024-03-08 02:18:20 +03:00
|
|
|
import google.auth
|
|
|
|
from google.auth.transport.requests import Request
|
2023-08-01 01:15:09 +03:00
|
|
|
|
|
|
|
from dataclasses import asdict
|
2024-03-08 02:18:20 +03:00
|
|
|
from datetime import datetime, timezone
|
2023-08-01 01:15:09 +03:00
|
|
|
import logging
|
2024-07-17 06:39:48 +03:00
|
|
|
from typing import Any, NotRequired, TypedDict
|
2023-08-01 01:15:09 +03:00
|
|
|
import requests
|
|
|
|
|
|
|
|
from framework import secrets
|
2024-05-16 05:43:30 +03:00
|
|
|
from framework import utils
|
|
|
|
from internals.core_models import Stage
|
2023-08-01 01:15:09 +03:00
|
|
|
from internals.data_types import OriginTrialInfo
|
|
|
|
import settings
|
|
|
|
|
|
|
|
|
2024-07-17 06:39:48 +03:00
|
|
|
class RequestTrial(TypedDict):
|
2024-09-12 18:00:33 +03:00
|
|
|
id: NotRequired[int]
|
2024-07-17 06:39:48 +03:00
|
|
|
display_name: str
|
|
|
|
start_milestone: str
|
|
|
|
end_milestone: str
|
|
|
|
end_time: dict
|
|
|
|
description: str
|
|
|
|
documentation_url: str
|
|
|
|
feedback_url: str
|
|
|
|
intent_to_experiment_url: str
|
|
|
|
chromestatus_url: str
|
|
|
|
allow_third_party_origins: bool
|
|
|
|
type: str
|
|
|
|
origin_trial_feature_name: NotRequired[str]
|
|
|
|
|
|
|
|
|
|
|
|
class InternalRegistrationConfig(TypedDict):
|
|
|
|
allow_public_suffix_subdomains: NotRequired[bool]
|
|
|
|
approval_type: NotRequired[str]
|
|
|
|
approval_buganizer_component_id: NotRequired[int]
|
|
|
|
approval_buganizer_custom_field_id: NotRequired[int]
|
|
|
|
approval_criteria_url: NotRequired[str]
|
|
|
|
approval_group_email: NotRequired[str]
|
|
|
|
|
|
|
|
|
|
|
|
class CreateOriginTrialRequest(TypedDict):
|
|
|
|
trial: RequestTrial
|
2024-07-24 03:42:35 +03:00
|
|
|
registration_config: InternalRegistrationConfig
|
|
|
|
trial_contacts: list[str]
|
2024-07-17 06:39:48 +03:00
|
|
|
|
|
|
|
|
2024-09-12 18:00:33 +03:00
|
|
|
class SetUpTrialRequest(TypedDict):
|
|
|
|
trial_id: int
|
|
|
|
announcement_groups_owners: list[str]
|
|
|
|
trial_contacts: list[str]
|
|
|
|
|
|
|
|
|
2023-08-01 01:15:09 +03:00
|
|
|
def get_trials_list() -> list[dict[str, Any]]:
|
|
|
|
"""Get a list of all origin trials.
|
2024-04-18 20:37:28 +03:00
|
|
|
|
2023-08-01 01:15:09 +03:00
|
|
|
Returns:
|
|
|
|
A list of data on all public origin trials.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
requests.exceptions.RequestException: If the request fails to connect or
|
|
|
|
the HTTP status code is not successful.
|
|
|
|
KeyError: If the response from the OT API is not in the expected format.
|
|
|
|
"""
|
|
|
|
key = secrets.get_ot_api_key()
|
|
|
|
# Return an empty list if no API key is found.
|
2024-04-18 20:37:28 +03:00
|
|
|
if key is None:
|
2023-08-01 01:15:09 +03:00
|
|
|
return []
|
|
|
|
|
|
|
|
try:
|
|
|
|
response = requests.get(
|
|
|
|
f'{settings.OT_API_URL}/v1/trials',
|
|
|
|
params={'prettyPrint': 'false', 'key': key})
|
|
|
|
response.raise_for_status()
|
|
|
|
except requests.exceptions.RequestException as e:
|
2024-03-08 02:18:20 +03:00
|
|
|
logging.exception('Failed to get response from origin trials API.')
|
2023-08-01 01:15:09 +03:00
|
|
|
raise e
|
|
|
|
|
|
|
|
response_json = response.json()
|
|
|
|
|
|
|
|
trials_list = [asdict(OriginTrialInfo(api_trial))
|
|
|
|
for api_trial in response_json['trials']
|
|
|
|
if api_trial['isPublic']]
|
|
|
|
return trials_list
|
2024-03-08 02:18:20 +03:00
|
|
|
|
|
|
|
|
2024-04-22 20:19:36 +03:00
|
|
|
def _get_trial_end_time(end_milestone: int) -> int:
|
2024-03-08 02:18:20 +03:00
|
|
|
"""Get the end time of the origin trial based on end milestone.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
The end time of the origin trial, represented in seconds since epoch.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
requests.exceptions.RequestException: If the request fails to connect or
|
|
|
|
the HTTP status code is not successful.
|
|
|
|
KeyError: If the response from Chromium schedule API is not in the expected
|
|
|
|
format.
|
|
|
|
"""
|
|
|
|
milestone_plus_two = int(end_milestone) + 2
|
2024-05-16 05:43:30 +03:00
|
|
|
mstone_info = utils.get_chromium_milestone_info(milestone_plus_two)
|
2024-03-08 02:18:20 +03:00
|
|
|
|
|
|
|
# Raise error if the response is not in the expected format.
|
2024-05-16 05:43:30 +03:00
|
|
|
if ('mstones' not in mstone_info
|
|
|
|
or len(mstone_info['mstones']) == 0
|
|
|
|
or 'late_stable_date' not in mstone_info['mstones'][0]):
|
2024-03-08 02:18:20 +03:00
|
|
|
raise KeyError('Chromium schedule response not in expected format.')
|
|
|
|
date = datetime.strptime(
|
2024-05-16 05:43:30 +03:00
|
|
|
mstone_info['mstones'][0]['late_stable_date'],
|
|
|
|
utils.CHROMIUM_SCHEDULE_DATE_FORMAT)
|
2024-03-08 02:18:20 +03:00
|
|
|
return int(date.replace(tzinfo=timezone.utc).timestamp())
|
|
|
|
|
|
|
|
|
|
|
|
def _get_ot_access_token() -> str:
|
|
|
|
"""Obtain the service account credentials to be used in the request
|
|
|
|
using the origin trials auth scope
|
2024-04-18 20:37:28 +03:00
|
|
|
|
2024-03-08 02:18:20 +03:00
|
|
|
Returns:
|
|
|
|
The access token to be used for origin trials requests.
|
|
|
|
"""
|
|
|
|
credentials, _ = google.auth.default(scopes=[
|
|
|
|
'https://www.googleapis.com/auth/chromeorigintrials'])
|
|
|
|
credentials.refresh(Request())
|
|
|
|
if credentials.token is None:
|
|
|
|
return ''
|
|
|
|
return credentials.token
|
|
|
|
|
|
|
|
|
2024-09-12 18:00:33 +03:00
|
|
|
def _send_create_trial_request(
|
|
|
|
ot_stage: Stage, api_key: str, access_token: str
|
|
|
|
) -> tuple[int|None, str|None]:
|
|
|
|
"""Send an origin trial creation request to the origin trials API.
|
|
|
|
Returns:
|
|
|
|
Newly created origin trial ID if trial was created, any error text if there
|
|
|
|
was an issue during the creation process.
|
2024-05-16 05:43:30 +03:00
|
|
|
"""
|
2024-07-17 06:39:48 +03:00
|
|
|
json: CreateOriginTrialRequest = {
|
2024-05-16 05:43:30 +03:00
|
|
|
'trial': {
|
|
|
|
'display_name': ot_stage.ot_display_name,
|
|
|
|
'start_milestone': str(ot_stage.milestones.desktop_first),
|
|
|
|
'end_milestone': str(ot_stage.milestones.desktop_last),
|
|
|
|
'end_time': {
|
|
|
|
'seconds': _get_trial_end_time(ot_stage.milestones.desktop_last)
|
|
|
|
},
|
|
|
|
'description': ot_stage.ot_description,
|
|
|
|
'documentation_url': ot_stage.ot_documentation_url,
|
|
|
|
'feedback_url': ot_stage.ot_feedback_submission_url,
|
|
|
|
'intent_to_experiment_url': ot_stage.intent_thread_url,
|
|
|
|
'chromestatus_url': f'{settings.SITE_URL}feature/{ot_stage.feature_id}',
|
|
|
|
'allow_third_party_origins': ot_stage.ot_has_third_party_support,
|
|
|
|
'type': ('DEPRECATION'
|
|
|
|
if ot_stage.ot_is_deprecation_trial else 'ORIGIN_TRIAL'),
|
2024-07-17 06:39:48 +03:00
|
|
|
},
|
2024-07-24 03:42:35 +03:00
|
|
|
'registration_config': {'approval_type': 'NONE'},
|
|
|
|
'trial_contacts': []
|
2024-05-16 05:43:30 +03:00
|
|
|
}
|
2024-07-17 06:39:48 +03:00
|
|
|
if ot_stage.ot_require_approvals:
|
2024-07-24 03:42:35 +03:00
|
|
|
json['registration_config'] = {
|
2024-07-17 06:39:48 +03:00
|
|
|
'approval_type': 'CUSTOM',
|
|
|
|
'approval_buganizer_component_id': ot_stage.ot_approval_buganizer_component,
|
|
|
|
'approval_criteria_url': ot_stage.ot_approval_criteria_url,
|
|
|
|
'approval_group_email': ot_stage.ot_approval_group_email,
|
|
|
|
'approval_buganizer_custom_field_id': ot_stage.ot_approval_buganizer_custom_field_id,
|
|
|
|
}
|
2024-09-12 18:00:33 +03:00
|
|
|
if ot_stage.ot_chromium_trial_name:
|
|
|
|
json['trial']['origin_trial_feature_name'] = ot_stage.ot_chromium_trial_name
|
2024-07-17 06:39:48 +03:00
|
|
|
if ot_stage.ot_is_deprecation_trial:
|
2024-07-24 03:42:35 +03:00
|
|
|
json['registration_config']['allow_public_suffix_subdomains'] = True
|
2024-09-12 18:00:33 +03:00
|
|
|
|
2024-05-16 05:43:30 +03:00
|
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
2024-09-12 18:00:33 +03:00
|
|
|
url = f'{settings.OT_API_URL}/v1/trials:initialize'
|
2024-05-16 05:43:30 +03:00
|
|
|
|
2024-09-12 18:00:33 +03:00
|
|
|
# Retry the request a number of times if any issues arise.
|
2024-05-16 05:43:30 +03:00
|
|
|
try:
|
|
|
|
response = requests.post(
|
2024-09-12 18:00:33 +03:00
|
|
|
url, headers=headers, params={'key': api_key}, json=json)
|
|
|
|
logging.info(f'CreateTrial response text: {response.text}')
|
2024-05-16 05:43:30 +03:00
|
|
|
response.raise_for_status()
|
2024-09-12 18:00:33 +03:00
|
|
|
except requests.exceptions.RequestException:
|
2024-05-16 05:43:30 +03:00
|
|
|
logging.exception(
|
|
|
|
f'Failed to get response from origin trials API. {response.text}')
|
2024-09-12 18:00:33 +03:00
|
|
|
return None, response.text
|
|
|
|
response_json = response.json()
|
|
|
|
return response_json['trial']['id'], None
|
|
|
|
|
|
|
|
|
|
|
|
def _send_set_up_trial_request(
|
|
|
|
trial_id: int,
|
|
|
|
owners: list[str],
|
|
|
|
contacts: list[str],
|
|
|
|
api_key: str,
|
|
|
|
access_token: str
|
|
|
|
) -> str|None:
|
|
|
|
"""Send an origin trial setup request to the origin trials API.
|
|
|
|
Returns:
|
|
|
|
Any error text if there was an issue during the setup process.
|
|
|
|
"""
|
|
|
|
json: SetUpTrialRequest = {
|
|
|
|
'trial_id': trial_id,
|
|
|
|
'announcement_groups_owners': owners,
|
|
|
|
'trial_contacts': contacts,
|
|
|
|
}
|
|
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
|
|
|
url = f'{settings.OT_API_URL}/v1/trials/{trial_id}:setup'
|
|
|
|
try:
|
|
|
|
response = requests.post(
|
|
|
|
url, headers=headers, params={'key': api_key}, json=json)
|
|
|
|
logging.info(f'SetUpTrial response text: {response.text}')
|
|
|
|
response.raise_for_status()
|
|
|
|
except requests.exceptions.RequestException:
|
|
|
|
logging.exception(
|
|
|
|
f'Failed to get response from origin trials API. {response.text}')
|
|
|
|
return response.text
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def create_origin_trial(ot_stage: Stage) -> tuple[str|None, str|None]:
|
|
|
|
"""Send an origin trial creation request to the origin trials API.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
requests.exceptions.RequestException: If the request fails to connect or
|
|
|
|
the HTTP status code is not successful.
|
|
|
|
Returns:
|
|
|
|
Newly created origin trial ID if trial was created, any error text if there
|
|
|
|
was an issue during the creation process.
|
|
|
|
"""
|
|
|
|
if settings.DEV_MODE:
|
|
|
|
logging.info('Creation request will not be sent to origin trials API in '
|
|
|
|
'local environment.')
|
|
|
|
return None, None
|
|
|
|
key = secrets.get_ot_api_key()
|
|
|
|
if key is None:
|
|
|
|
return None, 'No API key found for origin trials API'
|
2024-09-12 18:44:07 +03:00
|
|
|
ot_support_emails = secrets.get_ot_support_emails()
|
|
|
|
if ot_support_emails is None:
|
|
|
|
return None, 'OT support emails not found'
|
2024-09-12 18:00:33 +03:00
|
|
|
|
|
|
|
# Get a list of all OT @google.com contacts (ot_owner_email must be a google
|
|
|
|
# contact).
|
|
|
|
unique_contacts = [ot_stage.ot_owner_email]
|
|
|
|
unique_contacts.extend(ot_stage.ot_emails)
|
|
|
|
unique_contacts = [email for email in set(unique_contacts)
|
|
|
|
if email.endswith('@google.com')]
|
|
|
|
# A trial must have a google.com domain contact.
|
|
|
|
if len(unique_contacts) == 0:
|
|
|
|
return None, 'No trial contacts found in google.com domain'
|
|
|
|
|
|
|
|
access_token = _get_ot_access_token()
|
|
|
|
origin_trial_id, error_text = _send_create_trial_request(
|
|
|
|
ot_stage, key, access_token)
|
|
|
|
|
|
|
|
if origin_trial_id is None:
|
|
|
|
return None, error_text
|
|
|
|
|
|
|
|
error_text = _send_set_up_trial_request(
|
2024-09-12 18:44:07 +03:00
|
|
|
origin_trial_id,
|
|
|
|
ot_support_emails.split(','),
|
|
|
|
unique_contacts,
|
|
|
|
key,
|
|
|
|
access_token)
|
2024-09-12 18:00:33 +03:00
|
|
|
|
|
|
|
return str(origin_trial_id), error_text
|
2024-05-16 05:43:30 +03:00
|
|
|
|
|
|
|
|
|
|
|
def activate_origin_trial(origin_trial_id: str) -> None:
|
|
|
|
"""Activate an existing origin trial.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
requests.exceptions.RequestException: If the request fails to connect or
|
|
|
|
the HTTP status code is not successful.
|
|
|
|
"""
|
|
|
|
if settings.DEV_MODE:
|
|
|
|
logging.info('Activation request will not be sent to origin trials API in '
|
|
|
|
'local environment.')
|
|
|
|
return None
|
|
|
|
key = secrets.get_ot_api_key()
|
|
|
|
if key is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
json = {'id': origin_trial_id}
|
|
|
|
access_token = _get_ot_access_token()
|
|
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
2024-06-27 21:19:15 +03:00
|
|
|
url = (f'{settings.OT_API_URL}/v1/trials/{origin_trial_id}:start')
|
2024-05-16 05:43:30 +03:00
|
|
|
try:
|
|
|
|
response = requests.post(
|
|
|
|
url, headers=headers, params={'key': key}, json=json)
|
|
|
|
logging.info(response.text)
|
|
|
|
response.raise_for_status()
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
|
|
logging.exception(
|
|
|
|
f'Failed to get response from origin trials API. {response.text}')
|
|
|
|
raise e
|
|
|
|
|
|
|
|
|
2024-04-22 20:19:36 +03:00
|
|
|
def extend_origin_trial(trial_id: str, end_milestone: int, intent_url: str):
|
2024-03-08 02:18:20 +03:00
|
|
|
"""Extend an existing origin trial.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
requests.exceptions.RequestException: If the request fails to connect or
|
|
|
|
the HTTP status code is not successful.
|
|
|
|
"""
|
|
|
|
if settings.DEV_MODE:
|
|
|
|
logging.info('Extension request will not be sent to origin trials API in '
|
|
|
|
'local environment.')
|
|
|
|
return
|
|
|
|
key = secrets.get_ot_api_key()
|
|
|
|
# Return if no API key is found.
|
2024-04-18 20:37:28 +03:00
|
|
|
if key is None:
|
2024-03-08 02:18:20 +03:00
|
|
|
return
|
|
|
|
|
|
|
|
end_seconds = _get_trial_end_time(end_milestone)
|
|
|
|
access_token = _get_ot_access_token()
|
|
|
|
url = (f'{settings.OT_API_URL}/v1/trials/{trial_id}:add_extension')
|
|
|
|
headers = {'Authorization': f'Bearer {access_token}'}
|
|
|
|
json = {
|
|
|
|
'trial_id': trial_id,
|
|
|
|
'end_date': {
|
|
|
|
'seconds': end_seconds
|
|
|
|
},
|
2024-04-22 20:19:36 +03:00
|
|
|
'milestone_end': str(end_milestone),
|
2024-03-08 02:18:20 +03:00
|
|
|
'extension_intent_url': intent_url,
|
|
|
|
}
|
|
|
|
|
|
|
|
try:
|
|
|
|
response = requests.post(
|
|
|
|
url, headers=headers, params={'key': key}, json=json)
|
2024-04-22 20:19:36 +03:00
|
|
|
logging.info(response.text)
|
2024-03-08 02:18:20 +03:00
|
|
|
response.raise_for_status()
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
|
|
logging.exception('Failed to get response from origin trials API.')
|
|
|
|
raise e
|