diff --git a/contrib/buildbot/abcbot.py b/contrib/buildbot/abcbot.py --- a/contrib/buildbot/abcbot.py +++ b/contrib/buildbot/abcbot.py @@ -22,6 +22,7 @@ # Setup global parameters conduit_token = os.getenv("TEAMCITY_CONDUIT_TOKEN", None) +db_file_no_ext = os.getenv("DATABASE_FILE_NO_EXT", None) tc_user = os.getenv("TEAMCITY_USERNAME", None) tc_pass = os.getenv("TEAMCITY_PASSWORD", None) phabricatorUrl = os.getenv( @@ -54,8 +55,12 @@ port = args.port log_file = args.log_file - app = server.create_server(tc, phab, slackbot, travis) - app.logger.setLevel(logging.INFO) + app = server.create_server( + tc, + phab, + slackbot, + travis, + db_file_no_ext=db_file_no_ext) formater = logging.Formatter( '[%(asctime)s] %(levelname)s in %(module)s: %(message)s') diff --git a/contrib/buildbot/server.py b/contrib/buildbot/server.py --- a/contrib/buildbot/server.py +++ b/contrib/buildbot/server.py @@ -10,11 +10,13 @@ from functools import wraps import hashlib import hmac +import logging import os from phabricator_wrapper import ( BITCOIN_ABC_PROJECT_PHID, ) import re +import shelve from shieldio import RasterBadge from shlex import quote from teamcity_wrapper import TeamcityRequestException @@ -43,9 +45,11 @@ ) -def create_server(tc, phab, slackbot, travis, jsonEncoder=None): +def create_server(tc, phab, slackbot, travis, + db_file_no_ext=None, jsonEncoder=None): # Create Flask app for use as decorator app = Flask("abcbot") + app.logger.setLevel(logging.INFO) # json_encoder can be overridden for testing if jsonEncoder: @@ -55,14 +59,53 @@ tc.set_logger(app.logger) travis.set_logger(app.logger) - # A collection of the known build targets - create_server.diff_targets = {} - - # Build status panel data - create_server.panel_data = {} + # Optionally persistable database + create_server.db = { + # A collection of the known build targets + 'diff_targets': {}, + # Build status panel data + 'panel_data': {}, + # Whether the last status check of master was green + 'master_is_green': True, + } + + # If db_file_no_ext is not None, attempt to restore old database state + if db_file_no_ext: + db_file = db_file_no_ext + '.db' + app.logger.info("Loading persisted state file '{}'...".format(db_file)) + if os.path.exists(db_file): + with shelve.open(db_file_no_ext, flag='r') as db: + for key in create_server.db.keys(): + if key in db: + create_server.db[key] = db[key] + app.logger.info( + "Restored key '{}' from persisted state in file '{}'".format( + key, db_file)) + else: + app.logger.info( + "Persisted state file '{}' does not exist. It will be created when written to.".format(db_file)) + app.logger.info("Done") + else: + app.logger.warning( + "No database file specified. State will not be persisted.") + + def persistDatabase(fn): + @wraps(fn) + def decorated_function(*args, **kwargs): + fn_ret = fn(*args, **kwargs) + + # Persist database after executed decorated function + if db_file_no_ext: + with shelve.open(db_file_no_ext) as db: + for key in create_server.db.keys(): + db[key] = create_server.db[key] + app.logger.debug("Persisted current state") + else: + app.logger.debug( + "No database file specified. Persisting state is being skipped.") - # Whether the last status check of master was green - create_server.master_is_green = True + return fn_ret + return decorated_function # This decorator specifies an HMAC secret environment variable to use for verifying # requests for the given route. Currently, we're using Phabricator to trigger these @@ -173,6 +216,7 @@ return SUCCESS, 200 @app.route("/build", methods=['POST']) + @persistDatabase def build(): buildTypeId = request.args.get('buildTypeId', None) ref = request.args.get('ref', 'refs/heads/master') @@ -188,16 +232,16 @@ }] build_id = tc.trigger_build(buildTypeId, ref, PHID, properties)['id'] - if PHID in create_server.diff_targets: - build_target = create_server.diff_targets[PHID] + if PHID in create_server.db['diff_targets']: + build_target = create_server.db['diff_targets'][PHID] else: build_target = BuildTarget(PHID) build_target.queue_build(build_id, abcBuildName) - create_server.diff_targets[PHID] = build_target - + create_server.db['diff_targets'][PHID] = build_target return SUCCESS, 200 @app.route("/buildDiff", methods=['POST']) + @persistDatabase def build_diff(): def get_mandatory_argument(argument): value = request.args.get(argument, None) @@ -226,8 +270,8 @@ 'runOnDiff', False)] - if target_phid in create_server.diff_targets: - build_target = create_server.diff_targets[target_phid] + if target_phid in create_server.db['diff_targets']: + build_target = create_server.db['diff_targets'][target_phid] else: build_target = BuildTarget(target_phid) @@ -243,7 +287,7 @@ properties)['id'] build_target.queue_build(build_id, build_name) - create_server.diff_targets[target_phid] = build_target + create_server.db['diff_targets'][target_phid] = build_target return SUCCESS, 200 @app.route("/land", methods=['POST']) @@ -372,6 +416,7 @@ return SUCCESS, 200 @app.route("/status", methods=['POST']) + @persistDatabase def buildStatus(): out = get_json_request_data(request) app.logger.info("Received /status POST with data: {}".format(out)) @@ -503,7 +548,7 @@ # If the list of project names has changed (project was added, deleted # or renamed, update the panel data accordingly. (removed_projects, added_projects) = dict_xor( - create_server.panel_data, project_ids, lambda key: {}) + create_server.db['panel_data'], project_ids, lambda key: {}) # Log the project changes if any if (len(removed_projects) + len(added_projects)) > 0: @@ -539,7 +584,7 @@ # Update the builds for project_id, project_builds in sorted( - create_server.panel_data.items()): + create_server.db['panel_data'].items()): build_type_ids = [build['teamcity_build_type_id'] for build in list( associated_builds.values()) if build['teamcity_project_id'] == project_id] @@ -684,7 +729,8 @@ update_coverage_panel(coverage_summary) # If we have a buildTargetPHID, report the status. - build_target = create_server.diff_targets.get(buildTargetPHID, None) + build_target = create_server.db['diff_targets'].get( + buildTargetPHID, None) if build_target is not None: phab.update_build_target_status(build_target, buildId, status) @@ -695,7 +741,7 @@ ) if build_target.is_finished(): - del create_server.diff_targets[buildTargetPHID] + del create_server.db['diff_targets'][buildTargetPHID] revisionPHID = phab.get_revisionPHID(branch) @@ -777,8 +823,8 @@ (buildFailures, testFailures) = tc.getLatestBuildAndTestFailures( 'BitcoinABC') if len(buildFailures) == 0 and len(testFailures) == 0: - if not create_server.master_is_green: - create_server.master_is_green = True + if not create_server.db['master_is_green']: + create_server.db['master_is_green'] = True slackbot.postMessage( 'dev', "Master is green again.") @@ -819,7 +865,7 @@ return SUCCESS, 200 # Only mark master as red for failures that are not flaky - create_server.master_is_green = False + create_server.db['master_is_green'] = False commitHashes = buildInfo.getCommits() newTask = phab.createBrokenBuildTask( diff --git a/contrib/buildbot/test/abcbot_fixture.py b/contrib/buildbot/test/abcbot_fixture.py --- a/contrib/buildbot/test/abcbot_fixture.py +++ b/contrib/buildbot/test/abcbot_fixture.py @@ -38,6 +38,8 @@ TEST_USER, TEST_PASSWORD).encode()).decode('utf-8') self.headers = {'Authorization': 'Basic ' + self.credentials} + self.db_file_no_ext = None + def setUp(self): self.phab = test.mocks.phabricator.instance() self.slackbot = test.mocks.slackbot.instance() @@ -48,7 +50,8 @@ self.phab, self.slackbot, self.travis, - test.mocks.fixture.MockJSONEncoder).test_client() + db_file_no_ext=self.db_file_no_ext, + jsonEncoder=test.mocks.fixture.MockJSONEncoder).test_client() def tearDown(self): pass diff --git a/contrib/buildbot/test/test_persist_database.py b/contrib/buildbot/test/test_persist_database.py new file mode 100755 --- /dev/null +++ b/contrib/buildbot/test/test_persist_database.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2020 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import json +import mock +import os +import server +import shelve +import unittest + +from build import BuildStatus +from teamcity_wrapper import BuildInfo +from test.abcbot_fixture import ABCBotFixture +import test.mocks.teamcity +from test.mocks.teamcity import DEFAULT_BUILD_ID +from test.test_endpoint_build import buildRequestQuery +from test.test_endpoint_status import statusRequestData + + +DB_FILE_NO_EXT = "test_database" +DB_FILE = DB_FILE_NO_EXT + '.db' + +BUILD_NAME = 'build-name' +BUILD_TYPE_ID = 'build-type-id' +BUILD_TARGET_PHID = 'build-target-PHID' + + +class PersistDataTestCase(ABCBotFixture): + def setUp(self): + self.db_file_no_ext = DB_FILE_NO_EXT + super().setUp() + + self.phab.get_file_content_from_master = mock.Mock() + self.phab.get_file_content_from_master.return_value = json.dumps({}) + + self.phab.set_text_panel_content = mock.Mock() + + self.teamcity.get_coverage_summary = mock.Mock() + self.teamcity.get_coverage_summary.return_value = None + + self.teamcity.getBuildInfo = mock.Mock() + self.teamcity.getBuildInfo.return_value = BuildInfo.fromSingleBuildResponse( + json.loads(test.mocks.teamcity.buildInfo().content) + ) + + self.travis.get_branch_status = mock.Mock() + self.travis.get_branch_status.return_value = BuildStatus.Success + + def tearDown(self): + if os.path.exists(DB_FILE): + os.remove(DB_FILE) + + def test_persist_diff_targets(self): + queryData = buildRequestQuery() + queryData.abcBuildName = BUILD_NAME + queryData.buildTypeId = BUILD_TYPE_ID + queryData.PHID = BUILD_TARGET_PHID + + triggerBuildResponse = test.mocks.teamcity.buildInfo( + test.mocks.teamcity.buildInfo_changes( + ['test-change']), buildqueue=True) + self.teamcity.session.send.return_value = triggerBuildResponse + response = self.app.post( + '/build{}'.format(queryData), + headers=self.headers) + self.assertEqual(response.status_code, 200) + + # Check the diff target state was persisted + self.assertTrue(os.path.exists(DB_FILE)) + with shelve.open(DB_FILE_NO_EXT, flag='r') as db: + self.assertIn('diff_targets', db) + self.assertIn(BUILD_TARGET_PHID, db['diff_targets']) + self.assertIn( + DEFAULT_BUILD_ID, + db['diff_targets'][BUILD_TARGET_PHID].builds) + self.assertEqual( + db['diff_targets'][BUILD_TARGET_PHID].builds[DEFAULT_BUILD_ID].build_id, + DEFAULT_BUILD_ID) + self.assertEqual( + db['diff_targets'][BUILD_TARGET_PHID].builds[DEFAULT_BUILD_ID].status, + BuildStatus.Queued) + self.assertEqual( + db['diff_targets'][BUILD_TARGET_PHID].builds[DEFAULT_BUILD_ID].name, + BUILD_NAME) + + # Restart the server, which we expect to restore the persisted state + del self.app + self.app = server.create_server( + self.teamcity, + self.phab, + self.slackbot, + self.travis, + db_file_no_ext=DB_FILE_NO_EXT, + jsonEncoder=test.mocks.fixture.MockJSONEncoder).test_client() + + data = statusRequestData() + data.buildName = BUILD_NAME + data.buildId = DEFAULT_BUILD_ID + data.buildTypeId = BUILD_TYPE_ID + data.buildTargetPHID = BUILD_TARGET_PHID + statusResponse = self.app.post( + '/status', headers=self.headers, json=data) + self.assertEqual(statusResponse.status_code, 200) + + self.phab.harbormaster.createartifact.assert_called_with( + buildTargetPHID=BUILD_TARGET_PHID, + artifactKey="{}-{}".format(BUILD_NAME, BUILD_TARGET_PHID), + artifactType="uri", + artifactData={ + "uri": self.teamcity.build_url( + "viewLog.html", + { + "buildTypeId": BUILD_TYPE_ID, + "buildId": DEFAULT_BUILD_ID, + }, + ), + "name": BUILD_NAME, + "ui.external": True, + }, + ) + + # Check the diff target was cleared from persisted state + with shelve.open(DB_FILE_NO_EXT, flag='r') as db: + self.assertNotIn(BUILD_TARGET_PHID, db['diff_targets']) + + +if __name__ == '__main__': + unittest.main()