diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..077c95d --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 \ No newline at end of file diff --git a/.github/scripts/validate.py b/.github/scripts/validate.py new file mode 100644 index 0000000..d214f67 --- /dev/null +++ b/.github/scripts/validate.py @@ -0,0 +1,292 @@ +import requests +import json +import sys +from http.cookies import SimpleCookie +import time +import os +import logging + +logging.basicConfig(level=logging.DEBUG) + +ENV_STACK_MAPPING = { + 'java21_0__maven': '58', + 'java21_0__gradle': '59', + 'java21_0__springboot': '60', + 'kotlin2_1_android13114758': '79', + 'node_22_mongo_8__mern': '78', + 'node_22_mongo_8__mean': '77', + 'java21_0_android13114758': '76', + 'cpp14': '75', + 'java21_0__cucumber': '74', + 'ruby3_4__rails': '73', + 'spark3_5': '72', + 'pyspark3_5': '71', + 'python3_12__django': '70', + 'php8_4__symfony': '69', + 'php8_4__laravel': '68', + 'php8_4__codeigniter': '67', + 'php8_4': '66', + 'dotnet8_0': '65', + 'nodejs22_16__vuejs': '64', + 'nodejs22_16': '63', + 'nodejs22_16__angularjs': '62', + 'nodejs22_16__reactjs': '61', + 'go1_23': '52', + 'jam__java_21__mongo8': '80', + 'dam__python3_12__mongo8': '81', + 'expo__node_22__react_native': '82', + 'flutter_web__node22': '83', + 'dam__python3_13__mongo8': '102', + 'python3_13__django': '93', + 'go1_26': '94', + 'python3_13__flask': '95', + 'python3_13__flask__psql': '96', + 'go1_26__mysql8': '98', + 'go1_26__mongo8': '100', + 'nodejs24': '97', + 'nodejs24__reactjs': '105', + 'nodejs24__angularjs': '106', + 'nodejs24__vuejs': '107', + 'java25_0__springboot': '108', + 'jam__springboot__mongo8': '109', + 'java25_0__maven': '111', +} + +STACK_ENV_MAPPING = { v: k for k, v in ENV_STACK_MAPPING.items() } + +STACKS = json.loads(os.environ['HACKERRANK_STACKS']) +TARGET_STACK = os.environ['TARGET_STACK'] +TARGET_STACKS = set(STACKS.values()) +SOLUTION_VALIDATION = False + +SESSION = requests.session() +SESSION.headers.update({ + "Authorization": f"Bearer {os.environ['HACKERRANK_TOKEN']}", + "cache-control": "no-cache", + "content-type": "application/json", +}) + +BASE_URL = "https://www.hackerrank.com/" + + +def question_exists(qid): + print('finding question in company library...') + + tag_to_search = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + + library_url = BASE_URL + f"x/api/v1/library?limit=1&library=personal_all&tags={tag_to_search},duplicate" + response = SESSION.request("GET", library_url) + + if response.status_code != 200: + raise Exception("Failed to find question") + + data = response.json() + + if len(data['model']['questions']) != 0: + return True, data['model']['questions'][0] + + return False, None + + +def clone_question(qid): + print('cloning question...') + + clone_url = BASE_URL + "x/api/v1/questions/clone" + tag_to_search = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + payload = json.dumps({ + "id": qid, + "name": f"Copy of {tag_to_search}" + }) + response = SESSION.request("POST", clone_url, data=payload) + + if response.status_code != 200: + raise Exception("Failed to clone question") + + data = response.json() + + return data['question'] + + +def get_target_subtype(question): + if TARGET_STACK != 'based_on_current_stack': + return TARGET_STACK + + environment_id = question['environment_id'] + sub_type = question['sub_type'] + if environment_id: + sub_type = STACK_ENV_MAPPING.get(str(environment_id)) + + if not STACKS.get(sub_type): + raise Exception("Target sub type not found") + + return STACKS[sub_type] + + +def update_question(question, qid): + target_sub_type = get_target_subtype(question) + + print(f"update question...{question['id']}..cloned..{target_sub_type}") + + original_tags = question['visible_tags_array'] + tag_to_add = f"{qid}-{TARGET_STACK}-solution" if SOLUTION_VALIDATION else f"{qid}-{TARGET_STACK}" + + if question.get("sub_type") == "custom_vm" and ".NET" in original_tags: + question["sub_type"] = "dotnet3" + + if tag_to_add not in original_tags: + original_tags.extend([tag_to_add, "duplicate"]) + + + update_url = BASE_URL + f"x/api/v1/questions/{question['id']}" + + payload = { + "visible_tags_array": original_tags, + } + if target_sub_type in ENV_STACK_MAPPING: + payload['environment_id'] = int(ENV_STACK_MAPPING[target_sub_type]) + payload['sub_type'] = None + else: + payload['sub_type'] = target_sub_type + payload['environment_id'] = None + + print(f"payload {payload}") + response = SESSION.request("PUT", update_url, data=json.dumps(payload)) + + if not question_updated(response, payload): + print("Cloned question still have old subtype, retrying") + response = SESSION.request("PUT", update_url, data=json.dumps(payload)) + + if not question_updated(response, payload): + raise Exception("Cloned question still have old subtype") + + +def question_updated(response, payload): + if response.status_code != 200: + raise Exception("Failed to update cloned question") + + data = response.json() + + sub_type_environment_id = data['model'].get('environment_id', None) or data['model'].get('sub_type', None) + print(f"Cloned question subtype {sub_type_environment_id}") + + if payload['sub_type'] is not None: + return sub_type_environment_id in TARGET_STACKS + elif payload['environment_id'] is not None: + return str(sub_type_environment_id) in STACK_ENV_MAPPING + else: + raise Exception("No sub type or environment id found") + + +def update_project_zip(question): + print('updating project zip...') + update_url = BASE_URL + f"x/api/v1/questions/{question['id']}/upload" + files = [ + ('source_file', ('project.zip', open('project.zip', 'rb'), 'application/zip')) + ] + del SESSION.headers['content-type'] + response = SESSION.post(update_url, files=files, data={'a': 1}) + + if response.status_code != 200: + print(response.status_code) + raise Exception("Failed to update project zip") + + SESSION.headers.update({'content-type': 'application/json'}) + + +def validate_question(question): + print('validating question...') + validate_url = BASE_URL + f"x/api/v1/questions/{question['id']}/validate_fullstack" + + response = SESSION.post(validate_url) + + if response.status_code != 200: + print('Error in validate_question', response.status_code, response.text) + raise Exception("Failed to start validation...") + + return response.json()['task_id'] + + +def check_validation_status(task_id): + print('polling on validation task with id', task_id) + poll_url = BASE_URL + f"x/api/v1/delayed_tasks/{task_id}/poll" + + while True: + time.sleep(5) + response = SESSION.get(poll_url) + + if response.status_code != 200: + raise Exception("Failed to get validation status", response.status_code, response.text) + + data = response.json() + if data['status_code'] == 2 or data['response']['additional_data']['valid'] == True: + print(data) + return data + + +def process_validator_response(validator_response): + print('processing validator response...') + if not validator_response['valid']: + for step, value in validator_response['data'].items(): + if value['valid'] == False: + print(step, value) + raise Exception(value['error']) + + print("validated successfully") + + scoring_output = validator_response['data']['validate_scoring_output'] + + if SOLUTION_VALIDATION and not scoring_output['details']: + raise Exception('scoring output details is empty') + + if scoring_output['details']: + score = scoring_output['details']['scoring_data']['score'] + print("scoring output is") + print(scoring_output) + print(f"score is {score}") + if SOLUTION_VALIDATION and score != 1: + raise Exception('Did not get full score') + if not SOLUTION_VALIDATION and score != 0: + raise Exception('Got full or partial score in question') + + +def main(solution=False): + global SOLUTION_VALIDATION + global SESSION + + try: + # id of original question from library + qid = sys.argv[1].split('-')[0] + print(f"detected qid is {qid}") + + if not qid.isdigit(): + raise Exception('Could not parse Question ID') + + # solution validation from args + branch_name = sys.argv[2] if len(sys.argv) == 3 else "" + print(f"detected branch name is {branch_name}") + + SOLUTION_VALIDATION = solution or "solution" in branch_name + if SOLUTION_VALIDATION: + print(f"Doing *****SOLUTION***** Validation") + SESSION.headers.update({ + "Authorization": f"Bearer {os.environ['SOLUTION_TOKEN']}" + }) + + # get clone of question if exists + exists, question = question_exists(qid) + + if not exists: + question = clone_question(qid) + print('question copy has id', question['id']) + update_question(question, qid) + update_project_zip(question) + task_id = validate_question(question) + validator_response = check_validation_status(task_id) + process_validator_response(validator_response['response']) + except Exception as e: + print(e) + os._exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/validate_devops.py b/.github/scripts/validate_devops.py new file mode 100644 index 0000000..f60404a --- /dev/null +++ b/.github/scripts/validate_devops.py @@ -0,0 +1,304 @@ +import requests +import json +import sys +import time +import os + +OS_MAP = { + "ubuntu": "ubuntu22", + "ubuntu18": "ubuntu22", + "ubuntu22": "ubuntu22", + "rhel7": "rhel8", + "rhel8": "rhel8", + "redhat": "redhat", + "agnostic": "agnostic", +} + +TARGET_OS = set(OS_MAP.values()) +SOLUTION_VALIDATION = False + +SESSION = requests.session() +SESSION.headers.update( + { + "Authorization": f"Bearer {os.environ['HACKERRANK_TOKEN']}", + "cache-control": "no-cache", + "content-type": "application/json", + } +) + +BASE_URL = "https://www.hackerrank.com/x/api/v1" + + +def question_exists(qid): + print("finding question in company library...") + + tag_to_search = f"{qid}-solution" if SOLUTION_VALIDATION else f"{qid}" + + library_url = f"{BASE_URL}/library?limit=1&library=personal_all&tags={tag_to_search},duplicate" + response = SESSION.request("GET", library_url) + + if response.status_code != 200: + raise Exception("Failed to find question") + + data = response.json() + + if len(data["model"]["questions"]) != 0: + return True, data["model"]["questions"][0] + + return False, None + + +def create_question(): + create_url = f"{BASE_URL}/questions" + qid = sys.argv[1].split("-")[0] + name = f"{qid} solution" if SOLUTION_VALIDATION else qid # use repo name as question name + sudorank_os = os.environ.get("SUDORANK_OS", None) + + if not sudorank_os: + raise Exception("SUDORANK_OS not set") + + print(f'creating question with name "{name}" and os "{sudorank_os}"') + + response = SESSION.request( + "POST", + create_url, + data=json.dumps( + { + "type": "sudorank", + "role_type": "devops", + "custom_testcase_allowed": "True", + "version": 3, + "name": f"Copy of {name}", + "sudorank_v3": True, + "recommended_duration": 5, + "sudorank_os": sudorank_os, + "sudorank_context": "root", + } + ), + ) + + if response.status_code != 200: + raise Exception("Failed to create question") + + data = response.json() + + return data["model"] + + +def clone_question(qid): + print("cloning question...") + + clone_url = f"{BASE_URL}/questions/clone" + name = f"{qid} solution" if SOLUTION_VALIDATION else qid + payload = json.dumps( + { + "id": qid, + "name": f"Copy of {name}", + } + ) + response = SESSION.request("POST", clone_url, data=payload) + + if response.status_code != 200: + raise Exception("Failed to clone question") + + data = response.json() + + return data["question"] + + +def read_file(script_name): + repo_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + ) + file_path = f"{repo_dir}/{script_name}.sh" + if not os.path.exists(file_path): + return "" + + return open(file_path, "r").read() + + +def solution_script_for_ubuntu(solution): + return f"""cat <<"EOF" > /tmp/solve.sh +{solution} +EOF +/bin/su -c "bash /tmp/solve.sh" - ubuntu +""" + + +def get_devops_scripts(target_os): + global SOLUTION_VALIDATION + + script_names = ["setup", "check", "solve", "cleanup"] + scripts = {script_name: read_file(script_name) for script_name in script_names} + + if SOLUTION_VALIDATION: + # TODO: add user based solution execution for other os as well + if target_os == "ubuntu22": + scripts["solve"] = solution_script_for_ubuntu(scripts["solve"]) + scripts["check"] = "({solve}) && ({check})".format( + solve=scripts["solve"], check=scripts["check"] + ) + + return scripts + + +def get_target_os(sudorank_os): + if not OS_MAP.get(sudorank_os, None): + raise Exception("Target os not found") + + return OS_MAP[sudorank_os] + + +def update_question(question, qid): + sudorank_os = question.get("sudorank_os", None) or question.get( + "sudorank_draft", {} + ).get("sudorank_os") + + print(f"update question...{question['id']}..cloned..{sudorank_os}") + + original_tags = question["visible_tags_array"] + tag_to_add = f"{qid}-solution" if SOLUTION_VALIDATION else f"{qid}" + + if tag_to_add not in original_tags: + original_tags.extend([tag_to_add, "duplicate"]) + + target_os = get_target_os(sudorank_os) + + update_url = f"{BASE_URL}/questions/{question['id']}" + + payload = json.dumps( + { + "visible_tags_array": original_tags, + "sudorank_os": target_os, + "sudorank_scripts": { + "language": "bash", + **get_devops_scripts(target_os), + }, + } + ) + print(f"payload {payload}") + response = SESSION.request("PUT", update_url, data=payload) + if response.status_code != 200: + raise Exception("Failed to update cloned question") + + if not os_updated(response.json()): + print("Cloned question still have old os, retrying") + response = SESSION.request("PUT", update_url, data=payload) + + if not os_updated(response): + raise Exception("Cloned question still have old os") + + +def os_updated(data): + sudorank_draft = data["model"]["sudorank_draft"] + print(f"sudorank_draft {sudorank_draft}") + + target_os = sudorank_draft["sudorank_os"] + print(f"Cloned question os {target_os}") + return target_os in TARGET_OS + + +def validate_question(question): + print("validating question...") + validate_url = f"{BASE_URL}/questions/{question['id']}/validate_sudorank" + + response = SESSION.post(validate_url) + print(f"validate response {response.content}") + + if response.status_code != 200: + raise Exception("Failed to start validation...") + + return response.json()["task_id"] + + +def check_validation_status(task_id): + print("polling on validation task with id", task_id) + poll_url = f"{BASE_URL}/delayed_tasks/{task_id}/poll" + + while True: + time.sleep(5) + response = SESSION.get(poll_url) + + if response.status_code != 200: + raise Exception( + "Failed to get validation status", response.status_code, response.text + ) + + data = response.json() + if ( + data["status_code"] == 2 + or data["response"]["additional_data"]["valid"] == True + ): + print(data) + return data + + +def process_validator_response(validator_response): + print("processing validator response...") + if not validator_response["valid"]: + for step, value in validator_response["data"].items(): + if value["valid"] == False: + print(step, value) + raise Exception(value["error"]) + + print("validated successfully") + + scoring_output = validator_response["data"]["validate_scoring_output"] + + if SOLUTION_VALIDATION and not scoring_output["details"]: + raise Exception("scoring output details is empty") + + if scoring_output["details"]: + score = scoring_output["details"]["scoring_data"]["score"] + print("scoring output is") + print(scoring_output) + print(f"score is {score}") + if SOLUTION_VALIDATION and score != 1: + raise Exception("Did not get full score") + if not SOLUTION_VALIDATION and score != 0: + raise Exception("Got full or partial score in question") + + +def main(solution=False): + global SOLUTION_VALIDATION + global SESSION + + try: + # id of original question from library + qid = sys.argv[1].split("-")[0] + print(f"detected qid is {qid}") + + if not qid.isdigit(): + raise Exception("Could not parse Question ID") + + SOLUTION_VALIDATION = solution + + if SOLUTION_VALIDATION: + print(f"Doing *****SOLUTION***** Validation") + SESSION.headers.update( + {"Authorization": f"Bearer {os.environ['SOLUTION_TOKEN']}"} + ) + + # get clone of question if exists + exists, question = question_exists(qid) + + if not exists: + try: + question = clone_question(qid) + except Exception as e: + print(e) # create question if clone fails + question = create_question() + + print("question copy has id", question["id"]) + update_question(question, qid) + task_id = validate_question(question) + validator_response = check_validation_status(task_id) + process_validator_response(validator_response["response"]) + except Exception as e: + print(e) + os._exit(1) + + +if __name__ == "__main__": + main(solution=False) + main(solution=True) diff --git a/.github/workflows/validate_devops_workflow.yml b/.github/workflows/validate_devops_workflow.yml new file mode 100644 index 0000000..0217a41 --- /dev/null +++ b/.github/workflows/validate_devops_workflow.yml @@ -0,0 +1,59 @@ +name: HackerRank DevOps Validation +on: + workflow_dispatch: + # accept inputs from the user for sudorank_os + inputs: + sudorank_os: + description: "SudoRank OS" + required: false + default: "ubuntu22" + type: choice + options: + - "ubuntu22" + - "rhel8" + - "agnostic" + +env: + SOLUTION_TOKEN: ${{ secrets.SOLUTION_TOKEN }} + HACKERRANK_TOKEN: ${{ secrets.QUESTION_TOKEN }} + HACKERRANK_STACKS: ${{ vars.HACKERRANK_STACKS }} +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 7 + steps: + - name: Validate repo + run: | + if [[ $REPO_NAME == [[:digit:]]* ]] + then + echo 'Repo is valid =>' $REPO_NAME + else + echo 'Repo not starting with digit =>' $REPO_NAME + exit 1 + fi + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + - name: Checkout branch + uses: actions/checkout@v6.0.3 + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "pypy3.9" + - run: pip install -r ./.github/scripts/requirements.txt + name: Install script dependencies + - run: python -u ./.github/scripts/validate_devops.py ${{ github.event.repository.name }} + name: Validate DevOps + env: + SUDORANK_OS: ${{ inputs.sudorank_os }} + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1.11.0 + id: find-pull-request + with: + branch: ${{ github.ref_name }} + - name: Approve Pull Request if found + uses: juliangruber/approve-pull-request-action@v2.1.0 + if: ${{ steps.find-pull-request.outputs.number != '' }} + with: + github-token: ${{ github.token }} + number: ${{ steps.find-pull-request.outputs.number }} diff --git a/.github/workflows/validate_workflow.yml b/.github/workflows/validate_workflow.yml new file mode 100644 index 0000000..743bebe --- /dev/null +++ b/.github/workflows/validate_workflow.yml @@ -0,0 +1,120 @@ +name: HackerRank Validation +on: + workflow_dispatch: + inputs: + targetStack: + description: "Target Stack" + required: true + default: "based_on_current_stack" + type: choice + options: + - based_on_current_stack + - cpp12_1 + - go1_20 + - nodejs18_15 + - nodejs18_15__vuejs + - nodejs18_15__reactjs + - nodejs18_15__angularjs + - java17_0__spring_boot + - java17_0__gradle + - java17_0__maven + - ruby3_2__rails + - python3_11__django + - dotnet6_0 + - spark3_4 + - pyspark3_4 + - php8_2 + - php8_2__codeigniter + - php8_2__symfony + - php8_2__laravel + - expo6_3__react_native + - kotlin1_8_android9477386 + - java17_0_android9477386 + - java21_0__springboot + - java21_0__gradle + - java21_0__maven + - kotlin2_1_android13114758 + - node_22_mongo_8__mern + - node_22_mongo_8__mean + - java21_0_android13114758 + - cpp14 + - java21_0__cucumber + - ruby3_4__rails + - spark3_5 + - pyspark3_5 + - python3_12__django + - php8_4__symfony + - php8_4__laravel + - php8_4__codeigniter + - php8_4 + - dotnet8_0 + - nodejs22_16__vuejs + - nodejs22_16 + - nodejs22_16__angularjs + - nodejs22_16__reactjs + - go1_23 + - jam__java_21__mongo8 + - dam__python3_12__mongo8 + - expo__node_22__react_native + - flutter_web__node22 + - dam__python3_13__mongo8 + - python3_13__django + - go1_26 + - python3_13__flask + - python3_13__flask__psql + - go1_26__mysql8 + - go1_26__mongo8 + - nodejs24 + - nodejs24__reactjs + - nodejs24__angularjs + - nodejs24__vuejs + - java25_0__springboot + - jam__springboot__mongo8 + - java25_0__maven + +env: + SOLUTION_TOKEN: ${{ secrets.SOLUTION_TOKEN }} + HACKERRANK_TOKEN: ${{ secrets.QUESTION_TOKEN }} + HACKERRANK_STACKS: ${{ vars.HACKERRANK_STACKS }} +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 7 + steps: + - name: Validate repo + run: | + if [[ $REPO_NAME == [[:digit:]]* ]] + then + echo 'Repo is valid =>' $REPO_NAME + else + echo 'Repo not starting with digit =>' $REPO_NAME + exit 1 + fi + shell: bash + env: + REPO_NAME: ${{ github.event.repository.name }} + - name: Checkout branch + uses: actions/checkout@v6.0.3 + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "pypy3.9" + - run: git archive --format=zip -o project.zip HEAD ':!.github' + name: Create Project zip + - run: pip install -r ./.github/scripts/requirements.txt + name: Install script dependencies + - run: python -u ./.github/scripts/validate.py ${{ github.event.repository.name }} ${{ github.ref_name }} + name: Validate + env: + TARGET_STACK: ${{ inputs.targetStack }} + - name: Find Pull Request + uses: juliangruber/find-pull-request-action@v1.11.0 + id: find-pull-request + with: + branch: ${{ github.ref_name }} + - name: Approve Pull Request if found + uses: juliangruber/approve-pull-request-action@v2.1.0 + if: ${{ steps.find-pull-request.outputs.number != '' }} + with: + github-token: ${{ github.token }} + number: ${{ steps.find-pull-request.outputs.number }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..960df97 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Adyen Risk Engine + +This Java Maven challenge is upgraded for Java 25. + +## Validation + +- Build and tests run with Maven on Java 25. +- Candidate-facing source, tests, and scoring entry points remain in the existing project layout. diff --git a/pom.xml b/pom.xml index b4c2e8f..1fd5652 100644 --- a/pom.xml +++ b/pom.xml @@ -1,65 +1,60 @@ - - + + 4.0.0 - com.hackerrank risk-engine 1.0 jar - Transaction Risk Engine Real-time transaction risk evaluation engine - UTF-8 - 21 - 21 5.9.3 + 25 + 25 - org.junit.jupiter junit-jupiter - ${junit.version} + 6.0.1 test - org.apache.maven.plugins maven-compiler-plugin - 3.11.0 + 3.15.0 - 21 - 21 + 25 - org.apache.maven.plugins maven-surefire-plugin - 3.0.0 + 3.5.6 **/*Test.java xml + ${project.build.directory}/surefire-reports + false + classes + 4 + true - org.apache.maven.plugins maven-jar-plugin - 3.3.0 + 3.4.2 @@ -68,7 +63,6 @@ -