Skip to content

Commit

Permalink
bug 1307169: Emergency shut off backend API (#450). r=bhearsum
Browse files Browse the repository at this point in the history
  • Loading branch information
allan.silva authored and bhearsum committed Jan 26, 2018
1 parent 87dec52 commit 515239f
Show file tree
Hide file tree
Showing 20 changed files with 933 additions and 29 deletions.
1 change: 1 addition & 0 deletions agent/balrogagent/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
SCHEDULED_CHANGE_ENDPOINTS = ['rules',
'releases',
'permissions',
'emergency_shutoff',
'required_signoffs/product',
'required_signoffs/permissions']

Expand Down
33 changes: 17 additions & 16 deletions agent/balrogagent/test/test_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async def testNoChanges(self, time_is_ready, telemetry_is_ready, request):
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 0)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testTimeBasedNotReadyRules(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -50,7 +50,7 @@ async def testTimeBasedNotReadyRules(self, time, time_is_ready, telemetry_is_rea
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testTimeBasedNotReadyReleases(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -60,7 +60,7 @@ async def testTimeBasedNotReadyReleases(self, time, time_is_ready, telemetry_is_
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testTimeBasedNotReadyPermissions(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -70,7 +70,7 @@ async def testTimeBasedNotReadyPermissions(self, time, time_is_ready, telemetry_
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testTimeBasedIsNotReadyRequiredSignoffs(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -87,7 +87,7 @@ async def testTimeBasedIsNotReadyRequiredSignoffs(self, time, time_is_ready, tel
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 2)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testTimeBasedIsReadyRules(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -97,7 +97,7 @@ async def testTimeBasedIsReadyRules(self, time, time_is_ready, telemetry_is_read
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 6)
self.assertEquals(request.call_count, 7)

@asynctest.patch("time.time")
async def testTimeBasedIsReadyReleases(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -107,7 +107,7 @@ async def testTimeBasedIsReadyReleases(self, time, time_is_ready, telemetry_is_r
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 6)
self.assertEquals(request.call_count, 7)

@asynctest.patch("time.time")
async def testTimeBasedIsReadyPermissions(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -117,7 +117,7 @@ async def testTimeBasedIsReadyPermissions(self, time, time_is_ready, telemetry_i
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 6)
self.assertEquals(request.call_count, 7)

@asynctest.patch("time.time")
async def testTimeBasedIsReadyRequiredSignoffs(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -134,7 +134,7 @@ async def testTimeBasedIsReadyRequiredSignoffs(self, time, time_is_ready, teleme
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 2)
self.assertEquals(request.call_count, 7)
self.assertEquals(request.call_count, 8)

@asynctest.patch("balrogagent.cmd.get_telemetry_uptake")
async def testTelemetryBasedNotReady(self, get_telemetry_uptake, time_is_ready, telemetry_is_ready, request):
Expand All @@ -144,7 +144,7 @@ async def testTelemetryBasedNotReady(self, get_telemetry_uptake, time_is_ready,
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 1)
self.assertEquals(time_is_ready.call_count, 0)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("balrogagent.cmd.get_telemetry_uptake")
async def testTelemetryBasedIsReady(self, get_telemetry_uptake, time_is_ready, telemetry_is_ready, request):
Expand All @@ -154,7 +154,7 @@ async def testTelemetryBasedIsReady(self, get_telemetry_uptake, time_is_ready, t
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 1)
self.assertEquals(time_is_ready.call_count, 0)
self.assertEquals(request.call_count, 6)
self.assertEquals(request.call_count, 7)

@asynctest.patch("time.time")
async def testMultipleEndpointsAtOnce(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -166,7 +166,7 @@ async def testMultipleEndpointsAtOnce(self, time, time_is_ready, telemetry_is_re
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 3)
self.assertEquals(request.call_count, 8)
self.assertEquals(request.call_count, 9)

@asynctest.patch("time.time")
async def testMultipleChangesOneEndpoint(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -181,11 +181,12 @@ async def testMultipleChangesOneEndpoint(self, time, time_is_ready, telemetry_is
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 3)
self.assertEquals(request.call_count, 8)
self.assertEquals(request.call_count, 9)
called_endpoints = [call[0][1] for call in request.call_args_list]
self.assertIn('/scheduled_changes/releases', called_endpoints)
self.assertIn('/scheduled_changes/permissions', called_endpoints)
self.assertIn('/scheduled_changes/rules', called_endpoints)
self.assertIn('/scheduled_changes/emergency_shutoff', called_endpoints)
self.assertIn('/scheduled_changes/releases/4/enact', called_endpoints)
self.assertIn('/scheduled_changes/releases/5/enact', called_endpoints)
self.assertIn('/scheduled_changes/releases/6/enact', called_endpoints)
Expand All @@ -204,7 +205,7 @@ async def testSignoffsPresent(self, time, time_is_ready, telemetry_is_ready, req
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 6)
self.assertEquals(request.call_count, 7)

@asynctest.patch("time.time")
async def testSignoffsAbsent(self, time, time_is_ready, telemetry_is_ready, request):
Expand All @@ -220,7 +221,7 @@ async def testSignoffsAbsent(self, time, time_is_ready, telemetry_is_ready, requ
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 1)
self.assertEquals(request.call_count, 5)
self.assertEquals(request.call_count, 6)

@asynctest.patch("time.time")
async def testRightEnactOrderForMultipleEndpointsAtOnce(self, time, time_is_ready, telemetry_is_ready, request):
Expand Down Expand Up @@ -253,7 +254,7 @@ async def testRightEnactOrderForMultipleEndpointsAtOnce(self, time, time_is_read
await self._runAgent(sc, request)
self.assertEquals(telemetry_is_ready.call_count, 0)
self.assertEquals(time_is_ready.call_count, 11)
self.assertEquals(request.call_count, 16)
self.assertEquals(request.call_count, 17)
called_endpoints = [call[0][1] for call in request.call_args_list]
self.assertLess(called_endpoints.index('/scheduled_changes/rules'), called_endpoints.index('/scheduled_changes/releases'))
self.assertLess(called_endpoints.index('/scheduled_changes/rules/1/enact'), called_endpoints.index('/scheduled_changes/rules/4/enact'))
Expand Down
13 changes: 13 additions & 0 deletions auslib/AUS.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,22 @@ def __init__(self):
self.rand = functools.partial(randint, 0, 99)
self.log = logging.getLogger(self.__class__.__name__)

def updates_are_disabled(self, product, channel):
where = dict(product=product, channel=channel)
emergency_shutoffs = dbo.emergencyShutoffs.select(where=where)
return bool(emergency_shutoffs)

def evaluateRules(self, updateQuery):
self.log.debug("Looking for rules that apply to:")
self.log.debug(updateQuery)

if self.updates_are_disabled(updateQuery['product'], updateQuery['channel']) or \
self.updates_are_disabled(updateQuery['product'], getFallbackChannel(updateQuery['channel'])):
log_message = 'Updates are disabled for {}/{}.'.format(
updateQuery['product'], updateQuery['channel'])
self.log.debug(log_message)
return None, None

rules = dbo.rules.getRulesMatchingQuery(
updateQuery,
fallbackChannel=getFallbackChannel(updateQuery['channel'])
Expand Down
64 changes: 64 additions & 0 deletions auslib/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ def rows_to_dicts(rows):
return map(dict, rows)


def _matchesRegex(foo, bar):
# Expand wildcards and use ^/$ to make sure we don't succeed on partial
# matches. Eg, 3.6* matches 3.6, 3.6.1, 3.6b3, etc.
# Channel length must be strictly greater than two
# And globbing is allowed at the end of channel-name only
if foo.endswith('*'):
if(len(foo) >= 3):
test = foo.replace('.', '\.').replace('*', '\*', foo.count('*') - 1)
test = '^{}.*$'.format(test[:-1])
if re.match(test, bar):
return True
return False
else:
return False
elif (foo == bar):
return True
else:
return False


class AlreadySetupError(Exception):

def __str__(self):
Expand Down Expand Up @@ -2109,6 +2129,7 @@ class Permissions(AUSTable):
to ["GMP"] allows the user to modify GMP releases, but not Firefox."""
allPermissions = {
"admin": ["products"],
"emergency_shutoff": ["actions", "products"],
"release": ["actions", "products"],
"release_locale": ["actions", "products"],
"release_read_only": ["actions", "products"],
Expand Down Expand Up @@ -2349,6 +2370,44 @@ def _putWatchdogValue(self, changed_by, value, where=None, transaction=None, dry
super(Dockerflow, self).update(where=where, what=value, changed_by=changed_by, transaction=transaction, dryrun=dryrun)


class EmergencyShutoffs(AUSTable):
def __init__(self, db, metadata, dialect):
self.table = Table('emergency_shutoffs', metadata,
Column('product', String(15), nullable=False, primary_key=True),
Column('channel', String(75), nullable=False, primary_key=True))
AUSTable.__init__(self, db, dialect, scheduled_changes=True, scheduled_changes_kwargs={"conditions": ["time"]})

def insert(self, changed_by, transaction=None, dryrun=False, **columns):
if not self.db.hasPermission(changed_by, "emergency_shutoff", "create", columns.get("product"), transaction):
raise PermissionDeniedError(
"{} is not allowed to shut off updates for product {}".format(changed_by, columns.get("product")))

ret = super(EmergencyShutoffs, self).insert(changed_by=changed_by, transaction=transaction, dryrun=dryrun, **columns)
if not dryrun:
return ret.inserted_primary_key

def getPotentialRequiredSignoffs(self, affected_rows, transaction=None):
potential_required_signoffs = []
row = affected_rows[-1]
where = {"product": row["product"]}
for rs in self.db.productRequiredSignoffs.select(where=where, transaction=transaction):
if not row.get("channel") or _matchesRegex(row["channel"], rs["channel"]):
potential_required_signoffs.append(rs)
return potential_required_signoffs

def delete(self, where, changed_by=None, old_data_version=None, transaction=None, dryrun=False, signoffs=None):
product = self.select(where=where, columns=[self.product], transaction=transaction)[0]["product"]
if not self.db.hasPermission(changed_by, "emergency_shutoff", "delete", product, transaction):
raise PermissionDeniedError("%s is not allowed to delete shutoffs for product %s" % (changed_by, product))

if not dryrun:
for current_rule in self.select(where=where, transaction=transaction):
potential_required_signoffs = self.getPotentialRequiredSignoffs([current_rule], transaction=transaction)
verify_signoffs(potential_required_signoffs, signoffs)

super(EmergencyShutoffs, self).delete(changed_by=changed_by, where=where, old_data_version=old_data_version, transaction=transaction, dryrun=dryrun)


class UTF8PrettyPrinter(pprint.PrettyPrinter):
"""Encodes strings as UTF-8 before printing to avoid ugly u'' style prints.
Adapted from http://stackoverflow.com/questions/10883399/unable-to-encode-decode-pprint-output"""
Expand Down Expand Up @@ -2505,6 +2564,7 @@ def setDburi(self, dburi, mysql_traditional_mode=False):
self.dockerflowTable = Dockerflow(self, self.metadata, dialect)
self.productRequiredSignoffsTable = ProductRequiredSignoffsTable(self, self.metadata, dialect)
self.permissionsRequiredSignoffsTable = PermissionsRequiredSignoffsTable(self, self.metadata, dialect)
self.emergencyShutoffsTable = EmergencyShutoffs(self, self.metadata, dialect)
self.metadata.bind = self.engine

def setDomainWhitelist(self, domainWhitelist):
Expand Down Expand Up @@ -2608,3 +2668,7 @@ def permissionsRequiredSignoffs(self):
@property
def dockerflow(self):
return self.dockerflowTable

@property
def emergencyShutoffs(self):
return self.emergencyShutoffsTable
115 changes: 115 additions & 0 deletions auslib/migrate/versions/031_add_emergency_shutoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from sqlalchemy import (
Table, Column, Integer, BigInteger, String, Boolean, MetaData)


metadata = MetaData()


emergency_shutoffs = Table(
'emergency_shutoffs', metadata,
Column('product', String(15), nullable=False, primary_key=True),
Column('channel', String(75), nullable=False, primary_key=True),
Column("data_version", Integer, nullable=False))


emergency_shutoffs_history = Table(
'emergency_shutoffs_history', metadata,
Column('change_id', Integer, primary_key=True, autoincrement=True),
Column('changed_by', String(100), nullable=False),
Column('product', String(15), nullable=False),
Column('channel', String(75), nullable=False),
Column("data_version", Integer))


emergency_shutoffs_scheduled_changes = Table(
'emergency_shutoffs_scheduled_changes', metadata,
Column("sc_id", Integer, primary_key=True, autoincrement=True),
Column("scheduled_by", String(100), nullable=False),
Column("complete", Boolean, default=False),
Column("change_type", String(50), nullable=False),
Column("data_version", Integer, nullable=False),
Column('base_product', String(15), nullable=False),
Column('base_channel', String(75), nullable=False),
Column("base_data_version", Integer))


emergency_shutoffs_scheduled_changes_history = Table(
'emergency_shutoffs_scheduled_changes_history', metadata,
Column("change_id", Integer, primary_key=True, autoincrement=True),
Column("changed_by", String(100), nullable=False),
Column("sc_id", Integer, nullable=False, autoincrement=True),
Column("scheduled_by", String(100)),
Column("complete", Boolean, default=False),
Column("change_type", String(50)),
Column("data_version", Integer),
Column('base_product', String(15)),
Column('base_channel', String(75)),
Column("base_data_version", Integer))


emergency_shutoffs_scheduled_changes_conditions = Table(
'emergency_shutoffs_scheduled_changes_conditions', metadata,
Column("sc_id", Integer, primary_key=True, autoincrement=True),
Column("data_version", Integer, nullable=False))


emergency_shutoffs_scheduled_changes_conditions_history = Table(
"emergency_shutoffs_scheduled_changes_conditions_history", metadata,
Column("change_id", Integer, primary_key=True, autoincrement=True),
Column("changed_by", String(100), nullable=False),
Column("sc_id", Integer, nullable=False),
Column("data_version", Integer))


emergency_shutoffs_scheduled_changes_signoffs = Table(
'emergency_shutoffs_scheduled_changes_signoffs', metadata,
Column("sc_id", Integer, primary_key=True, autoincrement=False),
Column("username", String(100), primary_key=True),
Column("role", String(50), nullable=False))


emergency_shutoffs_scheduled_changes_signoffs_history = Table(
'emergency_shutoffs_scheduled_changes_signoffs_history', metadata,
Column("change_id", Integer, primary_key=True, autoincrement=True),
Column("changed_by", String(100), nullable=False),
Column("sc_id", Integer, nullable=False, autoincrement=False),
Column("username", String(100), nullable=False),
Column("role", String(50)))


def upgrade(migrate_engine):
metadata.bind = migrate_engine
bigintType = BigInteger

if migrate_engine.name == 'sqlite':
bigintType = Integer

emergency_shutoffs_history.append_column(
Column('timestamp', bigintType, nullable=False))

emergency_shutoffs_scheduled_changes_history.append_column(
Column('timestamp', bigintType, nullable=False))

emergency_shutoffs_scheduled_changes_conditions.append_column(
Column("when", bigintType))
emergency_shutoffs_scheduled_changes_conditions_history.append_column(
Column('when', bigintType))
emergency_shutoffs_scheduled_changes_conditions_history.append_column(
Column('timestamp', bigintType, nullable=False))

emergency_shutoffs_scheduled_changes_signoffs_history.append_column(
Column('timestamp', bigintType, nullable=False))

metadata.create_all()


def downgrade(migrate_engine):
metadata.bind = migrate_engine
Table('emergency_shutoffs', metadata, autoload=True).drop()
Table('emergency_shutoffs_history', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes_history', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes_conditions', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes_conditions_history', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes_signoffs', metadata, autoload=True).drop()
Table('emergency_shutoffs_scheduled_changes_signoffs_history', metadata, autoload=True).drop()
Loading

0 comments on commit 515239f

Please sign in to comment.