blob: d0bdeccbf26b4f59bce1fc2be0f7ac22313f8fd4 [file] [log] [blame]
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import datetime
import mock
from google.appengine.ext import ndb
from components import auth
from testing_utils import testing
from go.chromium.org.luci.buildbucket.proto import common_pb2
from go.chromium.org.luci.buildbucket.proto import project_config_pb2
from test import test_util
from test.test_util import future
import errors
import model
import search
import user
class ValidateQueryTests(testing.AppengineTestCase):
def test_two_ranges(self):
q = search.Query(
create_time_low=datetime.datetime(2018, 1, 1),
build_high=1000,
)
err_pattern = r'mutually exclusive'
with self.assertRaisesRegexp(errors.InvalidInputError, err_pattern):
q.validate()
def test_project_and_bucket_ids(self):
q = search.Query(project='chromium', bucket_ids=['chromium/try'])
err_pattern = r'mutually exclusive'
with self.assertRaisesRegexp(errors.InvalidInputError, err_pattern):
q.validate()
def test_builder_without_bucket(self):
q = search.Query(builder='linux-rel')
err_pattern = r'builder requires non-empty bucket_ids'
with self.assertRaisesRegexp(errors.InvalidInputError, err_pattern):
q.validate()
class SearchTest(testing.AppengineTestCase):
INDEXED_TAG = test_util.INDEXED_TAG_STRING
def setUp(self):
super(SearchTest, self).setUp()
self.current_identity = auth.Identity('service', 'unittest')
self.patch(
'components.auth.get_current_identity',
side_effect=lambda: self.current_identity
)
self.now = datetime.datetime(2015, 1, 1)
self.patch('components.utils.utcnow', side_effect=lambda: self.now)
self.patch(
'config.get_bucket_async',
return_value=future({'deadbeef': project_config_pb2.Bucket(name='try')})
)
self.patch('user.buckets_by_perm_async', autospec=True)
self.patch('search.TagIndex.random_shard_index', return_value=0)
self.perms = test_util.mock_permissions(self)
self.mock_searchable_buckets('chromium/try')
def mock_searchable_buckets(self, *bucket_ids):
self.perms.clear()
for b in bucket_ids:
self.perms[b] = [user.PERM_BUILDS_LIST]
user.buckets_by_perm_async.return_value = future(set(bucket_ids))
def put_many_builds(self, count, **build_proto_fields):
return [self.put_build(**build_proto_fields) for _ in xrange(count)]
def put_build(self, **build_proto_fields):
"""Puts a build and updates tag index."""
build_proto_fields.setdefault('id', model.create_build_ids(self.now, 1)[0])
self.now += datetime.timedelta(seconds=1)
build = test_util.build(**build_proto_fields)
build.put()
index_entry = search.TagIndexEntry(
build_id=build.key.id(),
bucket_id=build.bucket_id,
)
for t in search.indexed_tags(build.tags):
search.add_to_tag_index_async(t, [index_entry]).get_result()
return build
def search(self, **query_attrs):
return search.search_async(search.Query(**query_attrs)).get_result()
def assert_equal_keys(self, first, second):
keys = lambda builds: [b.key for b in builds]
self.assertEqual(keys(first), keys(second))
def test_without_buckets(self):
self.mock_searchable_buckets('proj/try')
build1 = self.put_build(builder=dict(project='proj', bucket='try'))
build2 = self.put_build(builder=dict(project='proj', bucket='ci'))
builds, _ = self.search()
self.assertEqual(builds, [build1])
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build1])
# All buckets are available.
self.mock_searchable_buckets('proj/try', 'proj/ci')
builds, _ = self.search()
self.assertEqual(builds, [build2, build1])
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build2, build1])
# No buckets are available.
self.mock_searchable_buckets()
builds, _ = self.search()
self.assertEqual(builds, [])
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [])
def test_auth_error(self):
self.mock_searchable_buckets()
with self.assertRaises(auth.AuthorizationError):
self.search(bucket_ids=['chromium/try'])
def test_filter_by_tag(self):
build = self.put_build(tags=[dict(key='t', value='0')])
self.put_build()
builds, _ = self.search(tags=['t:0'])
self.assertEqual(builds, [build])
def test_filter_by_many_tags(self):
build1 = self.put_build(
tags=[
test_util.INDEXED_TAG,
dict(key='t', value='0'),
dict(key='t', value='1'),
]
)
self.put_build(tags=[
test_util.INDEXED_TAG,
dict(key='t', value='0'),
])
# Search by both tags.
builds, _ = self.search(tags=[self.INDEXED_TAG, 't:0', 't:1'],)
self.assertEqual(builds, [build1])
builds, _ = self.search(tags=['t:0', 't:1'])
self.assertEqual(builds, [build1])
def test_filter_by_build_address(self):
build = self.put_build(
tags=[dict(key='build_address', value='chromium/infra/1')]
)
builds, _ = self.search(tags=['build_address:chromium/infra/1'])
self.assertEqual(builds, [build])
def test_filter_by_project(self):
self.mock_searchable_buckets('chromium/try', 'v8/try')
build = self.put_build(builder=dict(project='chromium'))
self.put_build(builder=dict(project='v8'))
builds, _ = self.search(project='chromium')
self.assertEqual(builds, [build])
def test_filter_by_bucket(self):
self.mock_searchable_buckets('proj/1', 'proj/2')
build1 = self.put_build(builder=dict(project='proj', bucket='1'))
self.put_build(builder=dict(project='proj', bucket='2'))
builds, _ = self.search(bucket_ids=['proj/1'])
self.assertEqual(builds, [build1])
def test_filter_by_builder(self):
build1 = self.put_build(
builder=dict(project='chromium', bucket='try', builder='linux-rel')
)
self.put_build(
builder=dict(project='chromium', bucket='try', builder='mac-rel')
)
self.put_build(
builder=dict(project='chromium', bucket='ci', builder='linux-rel')
)
builds, _ = self.search(bucket_ids=['chromium/try'], builder='linux-rel')
self.assertEqual(builds, [build1])
def test_filter_by_project_indexed(self):
self.mock_searchable_buckets('proj1/bucket', 'proj2/bucket')
build1 = self.put_build(builder=dict(project='proj1', bucket='bucket'))
self.put_build(builder=dict(project='proj2', bucket='bucket'))
builds, _ = self.search(
project='proj1',
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [build1])
def test_filter_by_status_legacy(self):
scheduled_build = self.put_build(status=common_pb2.SCHEDULED)
self.put_build(status=common_pb2.SUCCESS)
builds, _ = self.search(status=search.StatusFilter.SCHEDULED)
self.assertEqual(builds, [scheduled_build])
builds, _ = self.search(
status=search.StatusFilter.SCHEDULED, tags=[self.INDEXED_TAG]
)
self.assertEqual(builds, [scheduled_build])
builds, _ = self.search(
status=search.StatusFilter.COMPLETED,
result=model.BuildResult.FAILURE,
tags=[self.INDEXED_TAG]
)
self.assertEqual(builds, [])
builds, _ = self.search(
status=search.StatusFilter.COMPLETED, result=model.BuildResult.FAILURE
)
self.assertEqual(builds, [])
builds, _ = self.search(status=search.StatusFilter.INCOMPLETE)
self.assertEqual(builds, [scheduled_build])
builds, _ = self.search(
status=search.StatusFilter.INCOMPLETE, tags=[self.INDEXED_TAG]
)
self.assertEqual(builds, [scheduled_build])
def test_filter_by_status(self):
scheduled_build = self.put_build(status=common_pb2.SCHEDULED)
self.put_build(status=common_pb2.STARTED)
builds, _ = self.search(status=common_pb2.SCHEDULED)
self.assertEqual(builds, [scheduled_build])
builds, _ = self.search(
status=common_pb2.SCHEDULED, tags=[self.INDEXED_TAG]
)
self.assertEqual(builds, [scheduled_build])
builds, _ = self.search(status=common_pb2.FAILURE, tags=[self.INDEXED_TAG])
self.assertEqual(builds, [])
builds, _ = self.search(status=common_pb2.FAILURE)
self.assertEqual(builds, [])
# test search by ENDED_MASK status
builds, _ = self.search(status=common_pb2.ENDED_MASK)
self.assertEqual(builds, [])
completed_build = self.put_build(status=common_pb2.SUCCESS)
builds, _ = self.search(status=common_pb2.ENDED_MASK)
self.assertEqual(builds, [completed_build])
def test_filter_by_created_by(self):
build1 = self.put_build(created_by='user:1@example.com')
self.put_build(created_by='user:2@example.com')
builds, _ = self.search(created_by='1@example.com')
self.assertEqual(builds, [build1])
builds, _ = self.search(
created_by='1@example.com',
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [build1])
def test_filter_by_build_id_range_lo(self):
builds = self.put_many_builds(count=5)
# make builds order same as search results order
builds.reverse()
actual, _ = self.search(build_low=builds[0].key.id())
self.assert_equal_keys(builds, actual)
actual, _ = self.search(build_low=builds[0].key.id() + 1)
self.assert_equal_keys(builds[1:], actual)
def test_filter_by_build_id_range_hi(self):
builds = self.put_many_builds(count=5)
# make builds order same as search results order
builds.reverse()
actual, _ = self.search(build_high=builds[-1].key.id())
self.assert_equal_keys(builds[:-1], actual)
actual, _ = self.search(build_high=builds[-1].key.id() + 1)
self.assert_equal_keys(builds, actual)
def test_filter_by_creation_time_range(self):
too_old = model.BEGINING_OF_THE_WORLD - datetime.timedelta(milliseconds=1)
old_time = datetime.datetime(2010, 2, 4)
new_time = datetime.datetime(2012, 2, 4)
old_build = self.put_build(
id=model.create_build_ids(old_time, 1)[0],
create_time=test_util.dt2ts(old_time),
)
new_build = self.put_build(
id=model.create_build_ids(new_time, 1)[0],
create_time=test_util.dt2ts(new_time),
)
# Test lower bound
builds, _ = self.search(create_time_low=too_old)
self.assertEqual(builds, [new_build, old_build])
builds, _ = self.search(create_time_low=old_time)
self.assertEqual(builds, [new_build, old_build])
builds, _ = self.search(
create_time_low=old_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [new_build, old_build])
builds, _ = self.search(create_time_low=new_time)
self.assertEqual(builds, [new_build])
builds, _ = self.search(
create_time_low=new_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [new_build])
# Test upper bound
builds, _ = self.search(create_time_high=too_old)
self.assertEqual(builds, [])
builds, _ = self.search(create_time_high=old_time)
self.assertEqual(builds, [])
builds, _ = self.search(
create_time_high=old_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [])
builds, _ = self.search(create_time_high=new_time)
self.assertEqual(builds, [old_build])
builds, _ = self.search(
create_time_high=new_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [old_build])
builds, _ = self.search(
create_time_high=(new_time + datetime.timedelta(milliseconds=1)),
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [new_build, old_build])
# Test both sides bounded
builds, _ = self.search(
create_time_low=new_time,
create_time_high=old_time,
)
self.assertEqual(builds, [])
builds, _ = self.search(
create_time_low=old_time,
create_time_high=new_time,
)
self.assertEqual(builds, [old_build])
builds, _ = self.search(
create_time_low=old_time,
create_time_high=new_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [old_build])
# Test reversed bounds
builds, _ = self.search(
create_time_low=new_time,
create_time_high=old_time,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [])
def test_filter_by_retry_of(self):
build1 = self.put_build()
build1.retry_of = 42
build1.put()
build2 = self.put_build()
build2.retry_of = 43
build2.put()
builds, _ = self.search(retry_of=42)
self.assertEqual(builds, [build1])
builds, _ = self.search(retry_of=42, tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build1])
def test_filter_by_retry_of_and_buckets(self):
self.put_build(id=42, builder=dict(bucket='try'))
build1 = self.put_build(builder=dict(bucket='try'))
build1.retry_of = 42
build1.put()
build2 = self.put_build(builder=dict(bucket='ci'))
build2.retry_of = 42
build2.put()
builds, _ = self.search(retry_of=42)
self.assertEqual(builds, [build1])
builds, _ = self.search(
retry_of=42,
tags=[self.INDEXED_TAG],
)
self.assertEqual(builds, [build1])
def test_filter_by_retry_of_with_auth_error(self):
self.mock_searchable_buckets()
orig_build = self.put_build(id=1)
retried_build = self.put_build(id=1)
retried_build.retry_of = orig_build.key.id()
retried_build.put()
with self.assertRaises(auth.AuthorizationError):
# The build we are looking for was a retry of a build that is in a bucket
# that we don't have access to.
self.search(retry_of=1)
with self.assertRaises(auth.AuthorizationError):
# The build we are looking for was a retry of a build that is in a bucket
# that we don't have access to.
self.search(retry_of=1, tags=[self.INDEXED_TAG])
def test_filter_by_created_by_with_bad_string(self):
with self.assertRaises(errors.InvalidInputError):
self.search(created_by='blah')
def test_filter_by_with_paging_using_datastore_query(self):
self.put_many_builds(count=10)
first_page, next_cursor = self.search(max_builds=3)
self.assertEqual(len(first_page), 3)
self.assertEqual(next_cursor, 'id>%d' % first_page[-1].key.id())
second_page, _ = self.search(max_builds=3, start_cursor=next_cursor)
self.assertEqual(len(second_page), 3)
# no cover due to a bug in coverage (http://stackoverflow.com/a/35325514)
self.assertTrue(any(new not in first_page for new in second_page)
) # pragma: no cover
def test_filter_by_with_paging_using_tag_index(self):
self.put_many_builds(20)
first_page, first_cursor = self.search(
tags=[self.INDEXED_TAG],
max_builds=10,
)
self.assertEqual(len(first_page), 10)
self.assertEqual(first_cursor, 'id>%d' % first_page[-1].key.id())
second_page, second_cursor = self.search(
tags=[self.INDEXED_TAG], max_builds=10, start_cursor=first_cursor
)
self.assertEqual(len(second_page), 10)
third_page, third_cursor = self.search(
tags=[self.INDEXED_TAG], max_builds=10, start_cursor=second_cursor
)
self.assertEqual(len(third_page), 0)
self.assertFalse(third_cursor)
def test_filter_by_with_bad_tags(self):
def test_bad_tag(tags):
with self.assertRaises(errors.InvalidInputError):
self.search(tags=tags)
test_bad_tag(['x'])
test_bad_tag([1])
test_bad_tag({})
test_bad_tag(1)
def test_filter_by_with_non_number_max_builds(self):
with self.assertRaises(errors.InvalidInputError):
self.search(tags=['a:b'], max_builds='a')
def test_filter_by_with_negative_max_builds(self):
with self.assertRaises(errors.InvalidInputError):
self.search(tags=['a:b'], max_builds=-2)
def test_filter_by_indexed_tag(self):
self.put_build(builder=dict(project='chromium', bucket='try'),)
self.put_build(builder=dict(project='chromium', bucket='secret'),)
build = self.put_build(
builder=dict(project='chromium', bucket='try'),
tags=[dict(key='buildset', value='2')]
)
builds, _ = self.search(tags=['buildset:2'], bucket_ids=['chromium/try'])
self.assertEqual(builds, [build])
def test_filter_by_indexed_tag_builder(self):
build = self.put_build(
builder=dict(project='chromium', bucket='try', builder='linux-rel'),
tags=[dict(key='buildset', value='2')]
)
self.put_build(
builder=dict(project='chromium', bucket='try', builder='mac-rel'),
tags=[dict(key='buildset', value='2')]
)
builds, _ = self.search(
tags=['buildset:2'], bucket_ids=['chromium/try'], builder='linux-rel'
)
self.assertEqual(builds, [build])
def test_filter_by_indexed_tag_ended_mask(self):
build = self.put_build(
builder=dict(project='chromium', bucket='try', builder='linux-rel'),
status=common_pb2.SUCCESS,
tags=[dict(key='buildset', value='2')]
)
self.put_build(
builder=dict(project='chromium', bucket='try', builder='mac-rel'),
status=common_pb2.STARTED,
tags=[dict(key='buildset', value='2')]
)
builds, _ = self.search(tags=['buildset:2'], status=common_pb2.ENDED_MASK)
self.assertEqual(builds, [build])
def test_filter_by_with_dup_tag_entries(self):
build = test_util.build(id=1)
build.put()
entry = search.TagIndexEntry(build_id=1, bucket_id='chromium/try')
search.TagIndex(
id=self.INDEXED_TAG,
entries=[entry, entry],
).put()
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build])
def test_filter_by_with_incomplete_index(self):
build = self.put_build(builder=dict(bucket='try'))
self.put_many_builds(10, builder=dict(bucket='ci')) # add unrelated builds
search.TagIndex(id=self.INDEXED_TAG, permanently_incomplete=True).put()
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build])
builds2, _ = self.search(tags=[self.INDEXED_TAG], start_cursor='id>0')
self.assertEqual(builds2, [build])
def test_filter_by_with_legacy_index(self):
build = test_util.build(id=1)
build.put()
idx = search.TagIndex(
id=self.INDEXED_TAG,
entries=[
search.TagIndexEntry(build_id=1),
# this entry will be deleted, because bucket_id could not be
# resolved.
search.TagIndexEntry(build_id=123),
],
)
idx.put()
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build])
idx = idx.key.get()
self.assertEqual(len(idx.entries), 1)
self.assertEqual(idx.entries[0].bucket_id, 'chromium/try')
def test_filter_by_with_no_tag_index(self):
builds, _ = self.search()
self.assertEqual(builds, [])
def test_filter_by_with_inconsistent_entries(self):
build = self.put_build()
will_be_deleted = self.put_build()
# index is updated.
will_be_deleted.key.delete()
buildset_will_change = self.put_build()
# index is updated.
buildset_will_change.tags = []
buildset_will_change.put()
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [build])
def test_filter_by_with_tag_index_cursor(self):
builds = self.put_many_builds(10)
builds.reverse()
res, cursor = self.search(
tags=[self.INDEXED_TAG], start_cursor='id>%d' % builds[-1].key.id()
)
self.assertEqual(res, [])
self.assertIsNone(cursor)
builds = self.put_many_builds(10, tags=[dict(key='buildset', value='2')])
builds.reverse()
res, cursor = self.search(
tags=['buildset:2'],
create_time_low=builds[5].create_time,
start_cursor='id>%d' % builds[7].key.id()
)
self.assertEqual(res, [])
self.assertIsNone(cursor)
res, cursor = self.search(
tags=['buildset:2'],
create_time_high=builds[7].create_time,
start_cursor='id>%d' % builds[5].key.id()
)
# create_time_high is exclusive
self.assertEqual(res, builds[8:])
self.assertIsNone(cursor)
def test_filter_by_with_id_prefix_cursor_but_no_inded_tag(self):
build = self.put_build()
builds, _ = self.search(start_cursor='id>1')
self.assertEqual(builds, [build])
def test_filter_by_with_experimental(self):
exp_build = self.put_build(input=dict(experimental=True))
prod_build = self.put_build(input=dict(experimental=False))
builds, _ = self.search()
self.assertEqual(builds, [prod_build])
builds, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(builds, [prod_build])
builds, _ = self.search(include_experimental=True)
self.assertEqual(builds, [prod_build, exp_build])
builds, _ = self.search(tags=[self.INDEXED_TAG], include_experimental=True)
self.assertEqual(builds, [prod_build, exp_build])
def test_filter_by_with_experimental_and_paging(self):
for _ in xrange(5):
self.put_build(input=dict(experimental=True))
prod_build = self.put_build(input=dict(experimental=False))
for _ in xrange(5):
self.put_build(input=dict(experimental=True))
builds, _ = self.search(max_builds=3)
self.assertEqual(builds, [prod_build])
builds, _ = self.search(max_builds=3, tags=[self.INDEXED_TAG])
self.assertEqual(builds, [prod_build])
def test_multiple_shard_of_tag_index(self):
# Add two builds into shard0 and 2 in shard1.
search.TagIndex.random_shard_index.return_value = 0
shard0_builds = self.put_many_builds(2)
search.TagIndex.random_shard_index.return_value = 1
shard1_builds = self.put_many_builds(2)
shard0 = search.TagIndex.make_key(0, self.INDEXED_TAG).get()
shard1 = search.TagIndex.make_key(1, self.INDEXED_TAG).get()
self.assertEqual({e.build_id for e in shard0.entries},
{b.key.id() for b in shard0_builds})
self.assertEqual({e.build_id for e in shard1.entries},
{b.key.id() for b in shard1_builds})
# Retrieve all builds from tag indexes.
expected = sorted(shard0_builds + shard1_builds, key=lambda b: b.key.id())
actual, _ = self.search(tags=[self.INDEXED_TAG])
self.assertEqual(expected, actual)
def test_bad_cursor(self):
with self.assertRaises(errors.InvalidInputError):
self.search(start_cursor='a bad cursor',)
def test_no_permissions(self):
self.mock_searchable_buckets()
with self.assertRaises(auth.AuthorizationError):
self.search(bucket_ids=['chromium/try'])
class TagIndexTest(testing.AppengineTestCase):
def test_zeroth_shard(self):
self.assertEqual(
search.TagIndex.make_key(0, 'a:b'),
ndb.Key(search.TagIndex, 'a:b'),
)
def test_positive_shard_index(self):
self.assertEqual(
search.TagIndex.make_key(1, 'a:b'),
ndb.Key(search.TagIndex, ':1:a:b'),
)
def test_random_shard_key(self):
with mock.patch('search.TagIndex.random_shard_index', return_value=2):
self.assertEqual(
search.TagIndex.random_shard_key('a:b'),
ndb.Key(search.TagIndex, ':2:a:b'),
)
class TagIndexMaintenanceTest(testing.AppengineTestCase):
def setUp(self):
super(TagIndexMaintenanceTest, self).setUp()
self.patch('search.TagIndex.random_shard_index', return_value=0)
def test_add_too_many_to_index(self):
limit = search.TagIndex.MAX_ENTRY_COUNT
entries = [
search.TagIndexEntry(build_id=i, bucket_id='chromium/try')
for i in xrange(limit * 2)
]
tag = 'a:b'
index_key = search.TagIndex.make_key(0, tag)
search.add_to_tag_index_async(tag, entries[:limit]).get_result()
self.assertFalse(index_key.get().permanently_incomplete)
search.add_to_tag_index_async(tag, entries[limit:]).get_result()
self.assertTrue(index_key.get().permanently_incomplete)
search.add_to_tag_index_async(tag, entries[limit:]).get_result()
self.assertTrue(index_key.get().permanently_incomplete)
def test_update_tag_indexes_async(self):
builds = [
test_util.build(
id=1,
builder=dict(project='chromium'),
tags=[
dict(key='buildset', value='common'),
dict(key='buildset', value='unique'),
]
),
test_util.build(
id=2,
builder=dict(project='v8'),
tags=[
dict(key='buildset', value='common'),
]
),
]
ndb.Future.wait_all(search.update_tag_indexes_async(builds))
common = search.TagIndex.get_by_id('buildset:common')
self.assertEqual(
{e.build_id for e in common.entries},
{1, 2},
)
unique = search.TagIndex.get_by_id('buildset:unique')
self.assertEqual(
{e.build_id for e in unique.entries},
{1},
)