diff --git a/.pylintrc b/.pylintrc index 9191f08..1bdae4a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,11 +4,11 @@ init-hook='import sys; sys.path.append("/usr/local/lib/python3.10/site-packages" [MESSAGES CONTROL] -disable=duplicate-code +disable=duplicate-code, f-string-without-interpolation [DESIGN] -max-locals=19 +max-locals=35 max-attributes=12 [FORMAT] diff --git a/README.md b/README.md index 68c9d9f..1c5836d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,21 @@ Select launch instance from template Select `LowEndOrchestrator` and use the default template. ![OrchTemplaceSelect](docs/images/CDOrchTemplateSelect.png) +## Configuring OAuth +Authentication and Access control is managed through an OAuth to GitHub. Starting the system for the first time requires a file named `env` in current working directory. An example `env.development` is provided that you may copy, and update to match the `secret`, `client_id`, and `callback_url` of your OAuth app. + +If the `env` file is not present, the application will not start, and it will emit the error `Can't find file env in current directory, not able to parse env properties, exiting.` If no `env` file is present in the working directory, when you deploy a new orchestration instances in AWS, the AWS User Data script will create one, in the home directory, using the contents of `env.defaults`. The default configuration is not correct, and OAuth will fail. Using the default configuration will allow the application to start, and respond to healthchecks. Please make sure to review the `env` file if you have any issues with authentication. + +### Access Control +To gain access to the application, a user must have membership in specific GitHub teams. The org and teams checked for membership are found in the `env` file. You may use multiple teams for access control by providing a comma separated list in the `env` file. Access to the application is checked on every HTTP request, and the application makes HTTP calls to GitHub to ensure the user has sufficient privileges to perform the requested action. There are two methods of access control: +- Using a web browser via OAuth: Click on the person icon in the top right corner to login. You will be redirected to GitHub to authenticate. +- Using HTTP command line: Pass the header `Authorization` with your valid GitHub token. The GitHub token must have `read:org` scope for the organization specified in the `env` file. + +Example of command line access +``` +curl -H 'Accept: application/json' -H 'Authorization: gho_bBB1bB1BBbbBbb1BBbBbBB1bbbb1BbbBB' http://127.0.0.1:4000/status +``` + ## Updating Orchestrator Job Configuration By default the setup will spin up a webservice with [Production Run from Jan 2024](meta-data/full-production-run-20240101.json). To change the job configuration you need to create your own JSON configuration, and restart the service to use the new JSON. **Note** need to use `nohup` on python webservice to keep the process running after ssh-shell exit. - Create your own JSON following the example formate from `test-simple-jobs.json` @@ -31,7 +46,7 @@ By default the setup will spin up a webservice with [Production Run from Jan 202 ## Replay Setup You can spin up as many replay nodes as you need. Replay nodes will continuously pick and process new jobs. Each replay host works on one job at a time before picking up the next job. Therefore a small number of replay hosts will process all the jobs given enough time. For example, if there are 100 replay slices configured at most 100 replay hosts, and as few as 1 replay host, may be utilized. -Before running the script for the first time you must populate the correct subnet, security group, and region information into a file on the orchestration node. You will find that file `~/replay-test/scripts/replayhost/env`. Not setting the correct values will prevent the script from starting instances. +Before running the script for the first time you must populate the correct subnet, security group, and region information into a file on the orchestration node. You will find that file `~/replay-test/scripts/replayhost/env`. Not setting the correct values will prevent the script from starting instances. To run the replay nodes ssh into the orchestrator node and run [run-replay-instance.sh](scripts/replayhost/run-replay-instance.sh). The script takes two arguments the first is the number of replay hosts to spin up. The second argument indicates this is a dry run, and don't start up the hosts. ``` diff --git a/config/nginx-replay-test.conf b/config/nginx-replay-test.conf index e6c9473..b597c3d 100644 --- a/config/nginx-replay-test.conf +++ b/config/nginx-replay-test.conf @@ -11,13 +11,19 @@ server { # root /var/www/html; - index progress.html; + index progress; server_name _; # pass these URLs to app - location ~ ^/(status|config|job|summary|healthcheck|replayhost|metrics|jobtimeoutcheck) { + location ~ ^/(oauthback|progress|grid|control|detail|status|config|job|summary|healthcheck|replayhost|metrics|jobtimeoutcheck|logout) { proxy_buffering off; proxy_pass http://127.0.0.1:4000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location = / { + return 301 /progress; } # everything else serve static content diff --git a/docs/http-service-calls.md b/docs/http-service-calls.md index 49bf9ee..1e775f9 100644 --- a/docs/http-service-calls.md +++ b/docs/http-service-calls.md @@ -5,6 +5,11 @@ - status - gets a replay nodes progress and state - config - get/sets the configuration data used to initialize the job - summary - progress of current run and reports any failed jobs +- oauthback - login callback from OAuth provider +- logout +- process - Dynamic HTML for summary page +- grid - Dynamic HTML with grid of jobs +- control - Dynamic HTML with controls to operate replays - healthcheck - gets 200/0K always ## Job @@ -53,7 +58,7 @@ When running replay tests we don't always known the expected integrity hash. For ## Summary (Progress) ### GET -Returns the following +`/summary` Returns the following - number of blocks processed - total number of blocks to process - jobs completed @@ -66,9 +71,15 @@ Content Type Support. - If the Accepts header is text-html returns html - If Accepts header is application/json returns json +## Authentication + +There are two request, `/oauthback` and `/logout`. +- `/oauthback` is the call back from the OAuth provider, and it is used to set the authentication cookie. This call performs separate web calls to make sure the user has the correct privileges and may be allowed access. +- `/logout` clears the cookie preventing access to the application. + ## Healthcheck -Always returns same value used for healthchecks +`/healthcheck` Always returns same value used for healthchecks ### GET Only get request is supported. Always returns body of `OK` with status `200` diff --git a/docs/operating-details.md b/docs/operating-details.md index 4a23c38..392c135 100644 --- a/docs/operating-details.md +++ b/docs/operating-details.md @@ -17,7 +17,6 @@ All scripts are under the `ubunutu` user. The `replay-test` github repository is - /home/ubuntu/scripts/process_orchestration_log.py : parses log to produce stats on timing ## Replay hosts - All scripts are under the `enf-replay` user. The `replay-test` github repository is cloned into this directory. ### `Top Level Items` @@ -33,7 +32,7 @@ All scripts are under the `enf-replay` user. The `replay-test` github repository - nodoes.log : log from syncing runing - nodeos-readonly.log : log from readonly spinup of nodoes - ### `Additional Items` +### `Additional Items` - /home/enf-replay/replay-test/replay-client/background_status_update.sh : background job that send progress updates to orchestration service - /home/enf-replay/replay-test/replay-client/config_operations.py : python script to HTTP POST integrity hash updates - /home/enf-replay/replay-test/replay-client/create-nodeos-dir-struct.sh : init dir structure diff --git a/env.development b/env.development new file mode 100644 index 0000000..8a22f4a --- /dev/null +++ b/env.development @@ -0,0 +1,8 @@ +client_id=11111111111111111111 +scope=read:org +client_secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +authorize_url=https://github.com/login/oauth/authorize +registered_callback=https://example.com/oauthback +access_token=https://github.com/login/oauth/access_token +user_info_url=https://api.github.com/user +team=ORG/TEAM_1, ORG/TEAM_2 diff --git a/orchestration-service/env_store.py b/orchestration-service/env_store.py new file mode 100644 index 0000000..1782e44 --- /dev/null +++ b/orchestration-service/env_store.py @@ -0,0 +1,48 @@ +"""Module writes and puts env vars""" +import sys +import os + +class EnvStore(): + """Class to manage env vars""" + + def __init__(self, file): + """initialize from file""" + self.env_name_values = {} + if os.path.exists(file): + with open('env', 'r', encoding='utf-8') as properties_file: + # Read and parse each line into a list of tuples (name, value) + for line in properties_file: + line = line.strip() + # Skip empty lines + if not line: + continue + # Assuming the line format is "name=value" or "name:value" + if '=' in line: + name, value = line.split('=', 1) + elif ':' in line: + name, value = line.split(':', 1) + else: + print(f"Line format in env file not recognized: {line}") + continue + self.env_name_values[name.strip()] = value.strip() + else: + sys.exit(f"Can't find file {file} in current directory, not able to parse env properties, exiting.") + + def get(self, key): + """get values""" + return self.env_name_values[key] + + def has(self,key): + """false if key not set or no value; otherwise true""" + if key not in self.env_name_values or not self.env_name_values[key]: + return False + return True + + def set(self, key, value): + """set values""" + self.env_name_values[key] = value + + def set_default(self, key, default_value): + """if key does not exist nor has previous value set""" + if key not in self.env_name_values or not self.env_name_values[key]: + self.env_name_values[key] = default_value diff --git a/orchestration-service/github_oauth.py b/orchestration-service/github_oauth.py new file mode 100644 index 0000000..c95a757 --- /dev/null +++ b/orchestration-service/github_oauth.py @@ -0,0 +1,126 @@ +"""modules to support oauth functions""" +import json +import requests + +class GitHubOauth(): + """helper functions to manage git hub oauth""" + + @staticmethod + def assemble_oauth_url(state, properties): + """assemble url for initial oauth request""" + return properties.get('authorize_url') \ + + f"?client_id={properties.get('client_id')}" \ + + f"&redirect_uri={properties.get('registered_callback')}" \ + + f"&scope={properties.get('scope')}" \ + + f"&state={state}" \ + + f"&allow_signup=false" + + @staticmethod + def get_oauth_access_token(code, properties): + """build url for the get token request""" + # construct http call to exchange tempory code for access token + params = { + 'client_id': properties.get('client_id'), + 'client_secret': properties.get('client_secret'), + 'code': code, + 'redirect_uri': properties.get('registered_callback') + } + # make post call to do exchange + exchange_response = requests.post(properties.get('access_token'), + params=params, + timeout=3, + headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + # if good get the token otherwise fail + # returns following params access_token, scope, token_type + if exchange_response.status_code == 200: + exchange_data = json.loads(exchange_response.content.decode('utf-8')) + return exchange_data['access_token'] + return None + + @staticmethod + def create_auth_string(bearer_token, user_info_url): + """get public profile information using token""" + # https request to get public profile data, login and avatar_url + user_avatar_response = requests.get(user_info_url, + timeout=3, + headers={ + 'Accept': 'application/vnd.github+json', + 'Authorization': f'Bearer {bearer_token}', + 'X-GitHub-Api-Version': '2022-11-28' + }) + if user_avatar_response.status_code == 200: + user_data = json.loads(user_avatar_response.content.decode('utf-8')) + return GitHubOauth.credentials_to_str(user_data['login'],user_data['avatar_url'],bearer_token) + return None + + @staticmethod + def check_membership(bearer_token, login, team_string): + """Check for team membership""" + if not login: + return False + # many contain many teams + for unit in team_string.split(','): + org, team = unit.split('/',1) + org = org.strip() + team = team.strip() + url = f'https://api.github.com/orgs/{org}/teams/{team}/members' + membership_check = requests.get(url, + timeout=3, + headers={ + 'Accept': 'application/vnd.github+json', + 'Authorization': f'Bearer {bearer_token}', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'App/OAuth/ReplayTest' + }) + if membership_check.status_code == 200: + members_list = json.loads(membership_check.content.decode('utf-8')) + for member in members_list: + if member['login'] == login: + return True + return False + + @staticmethod + def is_authorized(cookies, header_token, user_info_url, team_string): + """check for authorized token or cookie""" + + token = None + if 'replay_auth' in cookies and cookies['replay_auth']: + token = GitHubOauth.extract_token(cookies['replay_auth']) + elif header_token: + token = header_token.replace("Bearer ","") + if not token: + return False + + auth_string = GitHubOauth.create_auth_string(token, user_info_url) + login = GitHubOauth.extract_login(auth_string) + return GitHubOauth.check_membership(token, login, team_string) + + @staticmethod + def credentials_to_str(login, avatar_url, token): + """converts profile data to string sep by :""" + return token + ":" + login + ":" + avatar_url + + @staticmethod + def str_to_public_profile(data): + """converts str to array of profile data""" + if not data: + return [] + # return public profile data leaving off bearer token + return data.split(':', 2)[1:] + + @staticmethod + def extract_token(data): + """grabs the bearer token from string""" + if not data: + return [] + return data.split(':', 2)[0] + + @staticmethod + def extract_login(data): + """grabs the bearer token from string""" + if not data: + return [] + return data.split(':', 2)[1] diff --git a/orchestration-service/html_page.py b/orchestration-service/html_page.py new file mode 100644 index 0000000..2fd2360 --- /dev/null +++ b/orchestration-service/html_page.py @@ -0,0 +1,49 @@ +"""modules for assembling HTML pages from files""" + +class HtmlPage: + """class for assembling HTML pages from files""" + def __init__(self, html_dir): + self.html_dir = html_dir + if not self.html_dir.endswith('/'): + self.html_dir = self.html_dir + '/' + + def contents(self, file_name="progress.html"): + """Return contents of html files""" + + if file_name == '/progress': + file_name = 'progress.html' + elif file_name == '/grid': + file_name = 'grid.html' + elif file_name == '/control': + file_name = 'control.html' + elif file_name == '/detail': + file_name = 'detail.html' + + file_path = self.html_dir + file_name + with open(file_path, 'r', encoding='utf-8') as file: + # Read the file's contents into a string + file_contents = file.read() + return file_contents + + def profile_top_bar_html(self, login, avatar_url): + """return top bar with profile""" + return f'''
+ + + + + +

{login}

+
''' + + def default_top_bar_html(self, oauth_url): + """return top bar with no profile""" + return f'''
+ account_circle +
''' + + def not_authorized(self, message="Not Authorized: Please Log In"): + """return not autorized page contents""" + return f'''
+

{message}

+
''' diff --git a/orchestration-service/test/run-pytest.sh b/orchestration-service/test/run-pytest.sh index dcc8780..de659fb 100755 --- a/orchestration-service/test/run-pytest.sh +++ b/orchestration-service/test/run-pytest.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash export PYTHONPATH=..:meta-data:orchestration-service:orchestration-service/test:$PYTHONPATH +# copy in env file for testing purposes +cp -f ../../env.development env # setup test file for persistance testing cp ../../meta-data/test-simple-jobs.json ../../meta-data/test-modify-jobs.json pytest test_summary_report.py @@ -21,16 +23,16 @@ fi rm ../../meta-data/test-modify-jobs.json cp ../../meta-data/test-simple-jobs.json ../../meta-data/test-modify-jobs.json -# integration tests start up service -{ python3 ../web_service.py --config "../../meta-data/test-modify-jobs.json" --host 127.0.0.1 > /dev/null 2>&1 & } +# make client calls to web service with access control diabled +{ python3 ../web_service.py --config "../../meta-data/test-modify-jobs.json" --host 127.0.0.1 --html-dir "../../webcontent/" --disable-auth > /dev/null 2>&1 & } WEB_SERVICE_PID=$! # prevent tests running before service is up -sleep 3 +sleep 1 # now test web service pytest test_web_service.py -sleep 3 +sleep 1 DIFF_CNT=$(diff ../../meta-data/test-simple-jobs.json ../../meta-data/test-modify-jobs.json | grep "^>" | wc -l) if [ "$DIFF_CNT" -lt 1 ]; then @@ -39,7 +41,30 @@ if [ "$DIFF_CNT" -lt 1 ]; then exit 1 fi +# shutdown and cleanup +kill "$WEB_SERVICE_PID" +rm ../../meta-data/test-modify-jobs.json + +cp ../../meta-data/test-simple-jobs.json ../../meta-data/test-modify-jobs.json +# test access control, all web calls should fail with 403 +{ python3 ../web_service.py --config "../../meta-data/test-modify-jobs.json" --host 127.0.0.1 --html-dir "../../webcontent/" > /dev/null 2>&1 & } +WEB_SERVICE_PID=$! +# prevent tests running before service is up +sleep 1 + +# now test web service +pytest test_no_auth_api.py + +sleep 1 + +DIFF_CNT=$(diff ../../meta-data/test-simple-jobs.json ../../meta-data/test-modify-jobs.json | grep "^>" | wc -l) +if [ "$DIFF_CNT" -ne 0 ]; then + echo "ERROR meta-data modified during auth check expected no changes" + exit 1 +fi + # shutdown service clean up file kill "$WEB_SERVICE_PID" rm ../../meta-data/test-modify-jobs.json -rm orchestration.log +rm orchestration.log +rm ./env diff --git a/orchestration-service/test/test_no_auth_api.py b/orchestration-service/test/test_no_auth_api.py new file mode 100644 index 0000000..b01923b --- /dev/null +++ b/orchestration-service/test/test_no_auth_api.py @@ -0,0 +1,41 @@ +"""Module tests web API expecting 403 auth failure""" +import requests +import pytest +import re +import json + +@pytest.fixture(scope="module") +def setup_module(): + """setting some constants to avoid mis-spellings""" + setup = {} + setup['base_url']='http://127.0.0.1:4000' + + setup['plain_text_headers'] = { + 'Accept': 'text/plain; charset=utf-8', + 'Content-Type': 'text/plain; charset=utf-8', + } + + setup['json_headers'] = { + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + } + + setup['html_headers'] = { + 'Accept': 'text/html, application/xhtml+xml', + 'Content-Type': 'text/html; charset=utf-8', + } + + return setup + +def test_api_calls(setup_module): + """Run through all API calls and make sure they fail with 403""" + cntx = setup_module + + params = { 'nextjob': 1, 'sliceid': 3 } + api_calls = ['/job', '/status', '/config', '/summary', '/progress', '/grid', '/control', '/detail', '/logout'] + for path in api_calls: + response = requests.get(cntx['base_url'] + '/job', + params=params, + timeout=3, + headers=cntx['json_headers']) + assert response.status_code == 403 diff --git a/orchestration-service/test/test_web_service.py b/orchestration-service/test/test_web_service.py index de3957d..81ffca4 100644 --- a/orchestration-service/test/test_web_service.py +++ b/orchestration-service/test/test_web_service.py @@ -17,28 +17,32 @@ def setup_module(): setup['json_headers'] = { 'Accept': 'application/json', - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', } setup['html_headers'] = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', + 'Accept': 'text/html, application/xhtml+xml', + 'Content-Type': 'text/html; charset=utf-8', } - return setup + session = requests.Session() + session.cookies.set('replay_auth', 'ghb_12334dfjkhaf:foobar:https://example.com/myavatar.gif') + + return setup, session def test_job_redirect(setup_module): """Equivalent results request a job using the redirect, and request with 'nextjob' param""" - cntx = setup_module + cntx, session = setup_module # request a job test that /job follows redirect to /job?nextjob # both /job and /job?nextjob should result in the same response - follow_redir_response = requests.get(cntx['base_url'] + '/job', headers=cntx['plain_text_headers']) - assert len(follow_redir_response.headers['ETag']) > 30 + follow_redir_response = session.get(cntx['base_url'] + '/job', headers=cntx['plain_text_headers']) assert follow_redir_response.status_code == 200 + assert len(follow_redir_response.headers['ETag']) > 30 + params = { 'nextjob': 1 } - direct_response = requests.get(cntx['base_url'] + '/job', + direct_response = session.get(cntx['base_url'] + '/job', params=params, headers=cntx['plain_text_headers']) @@ -49,10 +53,11 @@ def test_job_redirect(setup_module): def test_get_nextjob(setup_module): """Validate the data returned differs by Content Type""" - cntx = setup_module + cntx, session = setup_module + # test plain text params = { 'nextjob': 1 } - response_plain = requests.get(cntx['base_url'] + '/job', params=params, headers=cntx['plain_text_headers']) + response_plain = session.get(cntx['base_url'] + '/job', params=params, headers=cntx['plain_text_headers']) assert response_plain.status_code == 200 print(response_plain.content.decode('utf-8')) @@ -62,7 +67,7 @@ def test_get_nextjob(setup_module): # test json params = { 'nextjob': 1 } - response_json = requests.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) + response_json = session.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) assert response_json.status_code == 200 # print (response_json.content.decode('utf-8')) @@ -72,10 +77,11 @@ def test_get_nextjob(setup_module): def test_update_job(setup_module): """Get a job update the status and validate the change is in place""" - cntx = setup_module + cntx, session = setup_module + # test plain text params = { 'nextjob': 1 } - response_first = requests.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) + response_first = session.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) assert response_first.status_code == 200 etag_value = response_first.headers['ETag'] @@ -91,7 +97,7 @@ def test_update_job(setup_module): # serialized dict to JSON when passing in # Add ETag Header cntx['json_headers']['ETag'] = etag_value - updated_first = requests.post(cntx['base_url'] + '/job', + updated_first = session.post(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers'], data=json.dumps(job_first_request)) @@ -101,7 +107,7 @@ def test_update_job(setup_module): # fetch again to validate update # validate status after change params = { 'jobid': job_first_request['job_id'] } - validate_job_request = requests.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) + validate_job_request = session.get(cntx['base_url'] + '/job', params=params, headers=cntx['json_headers']) validate_job = json.loads(validate_job_request.content.decode('utf-8')) assert validate_job['status'] == 'STARTED' @@ -110,7 +116,7 @@ def test_update_job(setup_module): jobparams = { 'jobid': validate_job['job_id'] } # add ETag Header cntx['json_headers']['ETag'] = etag_value - updated = requests.post(cntx['base_url'] + '/job', + updated = session.post(cntx['base_url'] + '/job', params=jobparams, headers=cntx['json_headers'], data=json.dumps(validate_job)) @@ -124,7 +130,7 @@ def test_update_job(setup_module): jobparams = { 'jobid': validate_job['job_id'] } # add previous older and bad ETag Header cntx['json_headers']['ETag'] = etag_value - updated = requests.post(cntx['base_url'] + '/job', + updated = session.post(cntx['base_url'] + '/job', params=jobparams, headers=cntx['json_headers'], data=json.dumps(validate_job)) @@ -135,7 +141,8 @@ def test_update_job(setup_module): def test_no_more_jobs(setup_module): """Using nextjob loop over the jobs updating status; eventually no more jobs and None is returned""" - cntx = setup_module + cntx, session = setup_module + params = { 'nextjob': 1 } _ok = True @@ -148,7 +155,7 @@ def test_no_more_jobs(setup_module): while _ok: nextjobparams = { 'nextjob': 1 } - response = requests.get(cntx['base_url'] + '/job', params=nextjobparams, headers=cntx['json_headers']) + response = session.get(cntx['base_url'] + '/job', params=nextjobparams, headers=cntx['json_headers']) # end on not found 404 if response.status_code == 404: _ok = False @@ -171,7 +178,7 @@ def test_no_more_jobs(setup_module): jobparams = { 'jobid': job['job_id'] } # add ETag to Request cntx['json_headers']['ETag'] = etag_value - updated = requests.post(cntx['base_url'] + '/job', + updated = session.post(cntx['base_url'] + '/job', params=jobparams, headers=cntx['json_headers'], data=json.dumps(job)) @@ -191,7 +198,7 @@ def test_no_more_jobs(setup_module): jobparams = { 'jobid': job['job_id'] } # Add ETag cntx['json_headers']['ETag'] = etag_db[job['job_id']] - updated = requests.post(cntx['base_url'] + '/job', + updated = session.post(cntx['base_url'] + '/job', params=jobparams, headers=cntx['json_headers'], data=json.dumps(job)) @@ -200,30 +207,30 @@ def test_no_more_jobs(setup_module): def test_status_reports(setup_module): """Request full status and check results""" - cntx = setup_module + cntx, session = setup_module - response = requests.get(cntx['base_url'] + '/status', headers=cntx['json_headers']) + response = session.get(cntx['base_url'] + '/status', headers=cntx['json_headers']) assert response.status_code == 200 assert len(response.content) > 500 - response = requests.get(cntx['base_url'] + '/status', headers=cntx['plain_text_headers']) + response = session.get(cntx['base_url'] + '/status', headers=cntx['plain_text_headers']) assert response.status_code == 200 assert len(response.content) > 500 - response = requests.get(cntx['base_url'] + '/status', headers=cntx['html_headers']) + response = session.get(cntx['base_url'] + '/status', headers=cntx['html_headers']) assert response.status_code == 200 assert len(response.content) > 500 # check position arg params = { 'sliceid': 1 } - response = requests.get(cntx['base_url'] + '/status', + response = session.get(cntx['base_url'] + '/status', params=params, headers=cntx['json_headers']) assert response.status_code == 200 assert len(response.content) > 50 - response = requests.get(cntx['base_url'] + '/status', + response = session.get(cntx['base_url'] + '/status', params=params, headers=cntx['plain_text_headers']) assert response.status_code == 200 assert len(response.content) > 50 - response = requests.get(cntx['base_url'] + '/status', + response = session.get(cntx['base_url'] + '/status', params=params, headers=cntx['html_headers']) assert response.status_code == 200 @@ -231,17 +238,17 @@ def test_status_reports(setup_module): def test_config_reports(setup_module): """Request full status and check results""" - cntx = setup_module + cntx, session = setup_module params = { 'sliceid': 1 } - response = requests.get(cntx['base_url'] + '/config', + response = session.get(cntx['base_url'] + '/config', params=params, headers=cntx['json_headers']) assert response.status_code == 200 assert len(response.content) > 200 params = { 'sliceid': 1 } - response = requests.get(cntx['base_url'] + '/config', + response = session.get(cntx['base_url'] + '/config', params=params, headers=cntx['html_headers']) assert response.status_code == 200 @@ -249,7 +256,7 @@ def test_config_reports(setup_module): def test_post_config(setup_module): """Update Config""" - cntx = setup_module + cntx, session = setup_module config = { "end_block_num": 324302525, @@ -257,7 +264,7 @@ def test_post_config(setup_module): } params = { 'sliceid': 3 } - responseBefore = requests.get(cntx['base_url'] + '/config', + responseBefore = session.get(cntx['base_url'] + '/config', params=params, headers=cntx['json_headers']) assert responseBefore.status_code == 200 @@ -265,14 +272,14 @@ def test_post_config(setup_module): assert configBefore['expected_integrity_hash'] != config['integrity_hash'] # make POST call; json passed in as string - update_config_response = requests.post(cntx['base_url'] + '/config', + update_config_response = session.post(cntx['base_url'] + '/config', headers=cntx['json_headers'], timeout=3, data=json.dumps(config)) assert update_config_response.status_code == 200 params = { 'sliceid': 3 } - responseAfter = requests.get(cntx['base_url'] + '/config', + responseAfter = session.get(cntx['base_url'] + '/config', params=params, headers=cntx['json_headers']) assert responseAfter.status_code == 200 @@ -283,7 +290,8 @@ def test_post_config(setup_module): def test_healthcheck(setup_module): """Test Healthcheck""" - cntx = setup_module + cntx, session = setup_module + html_response = requests.get(cntx['base_url'] + '/healthcheck',headers=cntx['html_headers']) assert html_response.status_code == 200 @@ -292,21 +300,50 @@ def test_healthcheck(setup_module): def test_summary(setup_module): """Test Summary Report Page""" - cntx = setup_module - html_response = requests.get(cntx['base_url'] + '/summary',headers=cntx['html_headers']) - text_response = requests.get(cntx['base_url'] + '/summary',headers=cntx['plain_text_headers']) - json_response = requests.get(cntx['base_url'] + '/summary',headers=cntx['json_headers']) + cntx, session = setup_module + + text_response = session.get(cntx['base_url'] + '/summary',headers=cntx['plain_text_headers']) + json_response = session.get(cntx['base_url'] + '/summary',headers=cntx['json_headers']) - assert html_response.status_code == 200 assert text_response.status_code == 200 assert json_response.status_code == 200 - html_content = html_response.content.decode('utf-8') text_content = text_response.content.decode('utf-8') json_content = json_response.content.decode('utf-8') - assert len(html_content) > 10 assert len(text_content) > 10 assert len(json_content) > 10 - assert html_content != text_content assert text_content != json_response - assert json_response != html_content + +def test_detail(setup_module): + """Test the Detail Job Page""" + cntx, session = setup_module + + response = session.get(cntx['base_url'] + '/detail', headers=cntx['html_headers']) + + assert response.status_code == 200 + html_content = response.content.decode('utf-8') + assert len(html_content) > 10 + +def test_dynamic_html(setup_module): + """Test progress , grid and controll""" + cntx, session = setup_module + + progress_response = session.get(cntx['base_url'] + '/progress',headers=cntx['html_headers']) + grid_response = session.get(cntx['base_url'] + '/grid',headers=cntx['html_headers']) + control_response = session.get(cntx['base_url'] + '/control',headers=cntx['html_headers']) + + assert progress_response.status_code == 200 + assert grid_response.status_code == 200 + assert control_response.status_code == 200 + + progress_html = progress_response.content.decode('utf-8') + grid_html = grid_response.content.decode('utf-8') + control_html = control_response.content.decode('utf-8') + + assert len(progress_html) > 1065 + assert len(grid_html) > 1065 + assert len(control_html) > 1065 + + assert progress_html != grid_html + assert progress_html != control_html + assert grid_html != control_html diff --git a/orchestration-service/web_service.py b/orchestration-service/web_service.py index cd6d1a6..9124814 100644 --- a/orchestration-service/web_service.py +++ b/orchestration-service/web_service.py @@ -3,23 +3,31 @@ import json import logging import sys +from datetime import datetime, timedelta from werkzeug.wrappers import Request, Response from werkzeug.serving import run_simple from werkzeug.http import generate_etag +from werkzeug.utils import redirect from report_templates import ReportTemplate from replay_configuration import ReplayConfigManager +from html_page import HtmlPage from job_status import JobManager from job_summary import JobSummary +from env_store import EnvStore +from github_oauth import GitHubOauth @Request.application # pylint: disable=too-many-return-statements disable=too-many-branches -# pylint: disable=too-many-statements +# pylint: disable=too-many-statements disable=used-before-assignment def application(request): """ using werkzeug and python create a web application that supports /job /status /healthcheck + /process /control /grid + /login /logout + /oauthback """ # /job GET request @@ -37,6 +45,19 @@ def application(request): Content Type {request.headers.get('Content-Type')} Accept {request.headers.get('Accept')} ETag {request.headers.get('ETag')}""") + + # auth check /progress /grid /control /detail are HTML pages + # /healthcheck does not require acess control + # /oauthback is called before access control is avalible + # they have their own auth flow and messages, so we skip them for out auth check + # this protects API calls + if request.path not in ['/progress', '/grid', '/control', '/detail', '/healthcheck', '/oauthback'] and \ + not (ALWAYS_ALLOW or GitHubOauth.is_authorized(request.cookies, + request.headers.get('Authorization'), + env_name_values.get('user_info_url'), + env_name_values.get('team'))): + return Response("Not Authorized", status=403) + if request.path == '/job': # Work through GET Requests first if request.method == 'GET': @@ -225,18 +246,108 @@ def application(request): return Response(ReportTemplate.summary_text_report(report_obj), \ content_type='text/plain; charset=uft-8') + elif request.path == '/logout': + response = redirect('/progress') + response.delete_cookie('replay_auth') + return response + + elif request.path in ['/progress', '/grid', '/control', '/detail']: + # save the referer passed back in /oauthback + # quote url encodes string + referring_url = request.path + + if ALWAYS_ALLOW or \ + GitHubOauth.is_authorized(request.cookies, + request.headers.get('Authorization'), + env_name_values.get('user_info_url'), + env_name_values.get('team')): + # Retrieve the auth cookie + cookie_value = request.cookies.get('replay_auth') + login, avatar_url = GitHubOauth.str_to_public_profile(cookie_value) + html_content = html_factory.contents('header.html') \ + + html_factory.profile_top_bar_html(login, avatar_url) \ + + html_factory.contents('navbar.html') \ + + html_factory.contents(request.path) \ + + html_factory.contents('footer.html') + else: + html_content = html_factory.contents('header.html') \ + + html_factory.default_top_bar_html(\ + GitHubOauth.assemble_oauth_url(referring_url, env_name_values)\ + ) \ + + html_factory.not_authorized() \ + + html_factory.contents('footer.html') + + return Response(html_content, content_type='text/html') + + elif request.path == '/oauthback': + # this is where we do the login + # state passed from the user, just the path to return to + referral_path = request.args.get('state') + + # build request to get access token from code + code = request.args.get('code') + + # hold token for very short time + bearer_token = GitHubOauth.get_oauth_access_token(code, env_name_values) + if bearer_token: + profile_data = GitHubOauth.create_auth_string(bearer_token, env_name_values.get('user_info_url')) + login, avatar_url = GitHubOauth.str_to_public_profile(profile_data) + is_authorized_member = GitHubOauth.check_membership(bearer_token, + login, + env_name_values.get('team')) + # wipe out token after getting profile data, and checking authorization + bearer_token = None + if is_authorized_member: + # Calculate the expiration time, 1 week (7 days) from now + expires = datetime.utcnow() + timedelta(days=7) + + html_content = html_factory.contents('header.html') \ + + html_factory.profile_top_bar_html(login, avatar_url) \ + + html_factory.contents('navbar.html') \ + + html_factory.contents(referral_path) \ + + html_factory.contents('footer.html') + + response = Response(html_content, content_type='text/html') + + # Build an html page using the referal path + # Set an HTTP cookie with the expiration time, with highest security + # Return response + response.set_cookie('replay_auth', + profile_data, + expires=expires, + secure=True, + httponly=True, + samesite='Strict') + return response + + # failed to get access token + no_token_html = html_factory.contents('header.html') \ + + html_factory.default_top_bar_html(GitHubOauth.assemble_oauth_url(referral_path, env_name_values)) \ + + html_factory.not_authorized("Auth Failed Could Not Retreive Access Token: Try Again") \ + + html_factory.contents('footer.html') + return Response(no_token_html, status=403, content_type='text/html') + return Response("Not found", status=404) if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Orchestration Service \ -to manage tests to replay on the antelope blockchain') + # env is only intended to hold oauth client, secret, and urls + env_name_values = EnvStore('env') + + parser = argparse.ArgumentParser( + description='Orchestration Service to manage tests to replay on the antelope blockchain' + ) parser.add_argument('--config', '-c', type=str, help='Path to config json') parser.add_argument('--port', type=int, default=4000, help='Port for web service') parser.add_argument('--host', type=str, default='0.0.0.0', help='Listening service name or ip') + parser.add_argument('--html-dir', type=str, default='/var/www/html/', + help='path to static html files') parser.add_argument('--log', type=str, default="orchestration.log", help="log file for service") + parser.add_argument('--disable-auth', action='store_true', + help="when set disables access control, used for testing") args = parser.parse_args() + ALWAYS_ALLOW = args.disable_auth # setup logging logging.basicConfig(filename=args.log, @@ -247,6 +358,8 @@ def application(request): logging.info("Orchestration Web Service Starting Up") logger = logging.getLogger('OrchWebSrv') + html_factory = HtmlPage(args.html_dir) + # remove this if Local config works if args.config is None: sys.exit("Must provide config with --config option") diff --git a/scripts/orchestrator-bootstrap.sh b/scripts/orchestrator-bootstrap.sh index 3ed4687..8cce904 100755 --- a/scripts/orchestrator-bootstrap.sh +++ b/scripts/orchestrator-bootstrap.sh @@ -30,8 +30,13 @@ ln -s /etc/nginx/sites-available/nginx-replay-test.conf /etc/nginx/sites-enabled cp -r /home/"${USER}"/replay-test/webcontent/* /var/www/html/ systemctl reload nginx +# copy the default env so the system will start +if [ ! -s /home/"${USER}"/env ]; then + cp /home/"${USER}"/replay-test/env.default /home/"${USER}"/env +fi + ## startup service in background ## sudo -i -u "${USER}" python3 /home/"${USER}"/replay-test/orchestration-service/web_service.py \ - --config /home/"${USER}"/replay-test/meta-data/full-production-run-20231130.json \ + --config /home/"${USER}"/replay-test/meta-data/full-production-run-20240101.json \ --host 0.0.0.0 \ --log /home/"${USER}"/orch-complete-timings.log & diff --git a/webcontent/control.html b/webcontent/control.html index 151af46..34a102b 100644 --- a/webcontent/control.html +++ b/webcontent/control.html @@ -1,56 +1,6 @@ - - - -Control - - - - - - - - -
- account_circle -
-
-
-

Page Not Yet Implemented

+
+

Not Implemented Yet

+
- - - diff --git a/webcontent/detail.html b/webcontent/detail.html index a6d5a78..ba76226 100644 --- a/webcontent/detail.html +++ b/webcontent/detail.html @@ -1,52 +1,3 @@ - - - -Job Detail - - - - - - - - - -
- account_circle -
-

Job Id

@@ -118,5 +69,3 @@

Configuration

document.getElementById("configdetail").innerHTML = configDetailList; } - - diff --git a/webcontent/footer.html b/webcontent/footer.html new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/webcontent/footer.html @@ -0,0 +1,2 @@ + + diff --git a/webcontent/global.css b/webcontent/global.css index 8962bc6..1b4b8e7 100644 --- a/webcontent/global.css +++ b/webcontent/global.css @@ -59,6 +59,14 @@ li p { padding: var(--padding); text-align: right; } +.topbar img { + height: 1em; +} +.topbar p { + display: block; + margin-top: 0px; + font-size: .75em; +} .maincontent { padding-left: var(--media-gutter); } diff --git a/webcontent/grid.html b/webcontent/grid.html index 97a8736..dce8c3d 100644 --- a/webcontent/grid.html +++ b/webcontent/grid.html @@ -1,52 +1,3 @@ - - - -All Jobs - - - - - - - - - -
- account_circle -
-

All Jobs

@@ -71,7 +22,7 @@

All Jobs

if (dataObj != undefined) { dataObj.forEach(function(job) { - str += '' str += ''+job.replay_slice_id+''; str += ''+formateDateTime(job.start_time)+'' @@ -89,5 +40,3 @@

All Jobs

document.getElementById("joblist").innerHTML = str; - - diff --git a/webcontent/header.html b/webcontent/header.html new file mode 100644 index 0000000..59eba8b --- /dev/null +++ b/webcontent/header.html @@ -0,0 +1,21 @@ + + + +Status Report + + + + + + + + + diff --git a/webcontent/navbar.html b/webcontent/navbar.html new file mode 100644 index 0000000..190ebbe --- /dev/null +++ b/webcontent/navbar.html @@ -0,0 +1,25 @@ + diff --git a/webcontent/progress.html b/webcontent/progress.html index e692caa..2990079 100644 --- a/webcontent/progress.html +++ b/webcontent/progress.html @@ -1,52 +1,3 @@ - - - -Status Report - - - - - - - - - -
- account_circle -
-