Add tenant as a config option

Allow user to set a tenant in one of the configuration file's
subsections, so that they don't have to provide the argument to the CLI
all the time.

Fix the API builds() call not building the API URL properly for a
white-labeled root URL.

Change-Id: Ib3b8b2be07ed580ac9f48738b9157a5d0e4d5e70
This commit is contained in:
Matthieu Huin 2021-04-23 23:08:01 +02:00
parent cd375aedeb
commit 49451a814a
7 changed files with 147 additions and 33 deletions

View File

@ -9,6 +9,7 @@ the ``url`` attribute set. The optional ``verify_ssl`` can be set to False to
disable SSL verifications when connecting to Zuul (defaults to True). An disable SSL verifications when connecting to Zuul (defaults to True). An
authentication token can also be stored in the configuration file under the attribute authentication token can also be stored in the configuration file under the attribute
``auth_token`` to avoid passing the token in the clear on the command line. ``auth_token`` to avoid passing the token in the clear on the command line.
A default tenant can also be set with the ``tenant`` attribute.
Here is an example of a ``.zuul.conf`` file that can be used with zuul-client: Here is an example of a ``.zuul.conf`` file that can be used with zuul-client:

View File

@ -6,5 +6,10 @@
[example] [example]
url=https://example.com/zuul/ url=https://example.com/zuul/
# A default tenant can be specified in the configuration file; it will be
# overriden by the --tenant argument if set.
# Note that specifying a tenant is not necessary with a white-labeled tenant API URL,
# see https://zuul-ci.org/docs/zuul/howtos/installation.html#white-labeled-tenant
tenant=mytenant
# verify_ssl=False # verify_ssl=False
auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

View File

@ -0,0 +1,5 @@
---
features:
- |
Add a "tenant" field in the configuration file, so that a user doesn't have
to specify this argument in the CLI when using a predefined configuration.

View File

@ -93,9 +93,9 @@ class TestApi(BaseTestCase):
'node_hold_expiration': 3600} 'node_hold_expiration': 3600}
) )
self.assertEqual(True, ah) self.assertEqual(True, ah)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
ah = client.autohold( ah = client.autohold(
'tenant', 'project', 'job', 1, None, 'reason', 1, 3600) 'tenant1', 'project', 'job', 1, None, 'reason', 1, 3600)
client.session.post.assert_called_with( client.session.post.assert_called_with(
'https://fake.zuul/api/project/project/autohold', 'https://fake.zuul/api/project/project/autohold',
json={'reason': 'reason', json={'reason': 'reason',
@ -132,7 +132,7 @@ class TestApi(BaseTestCase):
client.session.get.assert_called_with( client.session.get.assert_called_with(
'https://fake.zuul/api/tenant/tenant1/autohold') 'https://fake.zuul/api/tenant/tenant1/autohold')
self.assertEqual(fakejson, ahl) self.assertEqual(fakejson, ahl)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
ahl = client.autohold_list('tenant1') ahl = client.autohold_list('tenant1')
client.session.get.assert_called_with( client.session.get.assert_called_with(
'https://fake.zuul/api/autohold') 'https://fake.zuul/api/autohold')
@ -162,7 +162,7 @@ class TestApi(BaseTestCase):
'https://fake.zuul/api/tenant/tenant1/autohold/123' 'https://fake.zuul/api/tenant/tenant1/autohold/123'
) )
self.assertEqual(True, ahd) self.assertEqual(True, ahd)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
ahd = client.autohold_delete(123, 'tenant1') ahd = client.autohold_delete(123, 'tenant1')
client.session.delete.assert_called_with( client.session.delete.assert_called_with(
'https://fake.zuul/api/autohold/123' 'https://fake.zuul/api/autohold/123'
@ -194,7 +194,7 @@ class TestApi(BaseTestCase):
client.session.get.assert_called_with( client.session.get.assert_called_with(
'https://fake.zuul/api/tenant/tenant1/autohold/123') 'https://fake.zuul/api/tenant/tenant1/autohold/123')
self.assertEqual(fakejson, ahl) self.assertEqual(fakejson, ahl)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
ahl = client.autohold_info(tenant='tenant1', id=123) ahl = client.autohold_info(tenant='tenant1', id=123)
client.session.get.assert_called_with( client.session.get.assert_called_with(
'https://fake.zuul/api/autohold/123') 'https://fake.zuul/api/autohold/123')
@ -226,7 +226,7 @@ class TestApi(BaseTestCase):
'pipeline': 'check'} 'pipeline': 'check'}
) )
self.assertEqual(True, enq) self.assertEqual(True, enq)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
enq = client.enqueue('tenant1', 'check', 'project1', '1,1') enq = client.enqueue('tenant1', 'check', 'project1', '1,1')
client.session.post.assert_called_with( client.session.post.assert_called_with(
'https://fake.zuul/api/project/project1/enqueue', 'https://fake.zuul/api/project/project1/enqueue',
@ -265,7 +265,7 @@ class TestApi(BaseTestCase):
'pipeline': 'check'} 'pipeline': 'check'}
) )
self.assertEqual(True, enq_ref) self.assertEqual(True, enq_ref)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
enq_ref = client.enqueue_ref( enq_ref = client.enqueue_ref(
'tenant1', 'check', 'project1', 'refs/heads/stable', '0', '0') 'tenant1', 'check', 'project1', 'refs/heads/stable', '0', '0')
client.session.post.assert_called_with( client.session.post.assert_called_with(
@ -316,7 +316,7 @@ class TestApi(BaseTestCase):
'pipeline': 'check'} 'pipeline': 'check'}
) )
self.assertEqual(True, deq) self.assertEqual(True, deq)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
deq = client.dequeue('tenant1', 'check', 'project1', change='1,1') deq = client.dequeue('tenant1', 'check', 'project1', change='1,1')
client.session.post.assert_called_with( client.session.post.assert_called_with(
'https://fake.zuul/api/project/project1/dequeue', 'https://fake.zuul/api/project/project1/dequeue',
@ -359,7 +359,7 @@ class TestApi(BaseTestCase):
'pipeline': 'check'} 'pipeline': 'check'}
) )
self.assertEqual(True, prom) self.assertEqual(True, prom)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
prom = client.promote('tenant1', 'check', ['1,1', '2,1']) prom = client.promote('tenant1', 'check', ['1,1', '2,1'])
client.session.post.assert_called_with( client.session.post.assert_called_with(
'https://fake.zuul/api/promote', 'https://fake.zuul/api/promote',
@ -394,7 +394,7 @@ GuS6/ewjS+arA1Iyeg/IxmECAwEAAQ==
'https://fake.zuul/api/tenant/tenant1/key/project1.pub' 'https://fake.zuul/api/tenant/tenant1/key/project1.pub'
) )
self.assertEqual(pubkey, key) self.assertEqual(pubkey, key)
client.info_ = {'tenant': 'scoped'} client.info_ = {'tenant': 'tenant1'}
key = client.get_key('tenant1', 'project1') key = client.get_key('tenant1', 'project1')
client.session.get.assert_called_with( client.session.get.assert_called_with(
'https://fake.zuul/api/key/project1.pub' 'https://fake.zuul/api/key/project1.pub'

View File

@ -63,6 +63,61 @@ class TestCmd(BaseTestCase):
'--reason', 'some reason', '--reason', 'some reason',
'--node-hold-expiration', '3600']) '--node-hold-expiration', '3600'])
def test_use_conf(self):
"""Test that CLI can use a configuration file"""
ZC = ZuulClient()
with tempfile.NamedTemporaryFile(delete=False) as conf_file:
conf_file.write(
b"""
[confA]
url=https://my.fake.zuul/
tenant=mytenant
auth_token=mytoken
verify_ssl=True"""
)
conf_file.close()
with patch("requests.Session") as mock_sesh:
session = mock_sesh.return_value
session.post = MagicMock(
return_value=FakeRequestResponse(200, True)
)
session.get = MagicMock(side_effect=mock_get())
exit_code = ZC._main(
[
"-c",
conf_file.name,
"--use-config",
"confA",
"autohold",
"--project",
"project1",
"--job",
"job1",
"--change",
"3",
"--reason",
"some reason",
"--node-hold-expiration",
"3600",
]
)
self.assertEqual("mytoken", ZC.get_client().auth_token)
self.assertEqual(True, ZC.get_client().verify)
session.post.assert_called_with(
"https://my.fake.zuul/api/tenant/mytenant/"
"project/project1/autohold",
json={
"reason": "some reason",
"count": 1,
"job": "job1",
"change": "3",
"ref": "",
"node_hold_expiration": 3600,
},
)
self.assertEqual(0, exit_code)
os.unlink(conf_file.name)
def test_tenant_scoping_errors(self): def test_tenant_scoping_errors(self):
"""Test the right uses of --tenant""" """Test the right uses of --tenant"""
ZC = ZuulClient() ZC = ZuulClient()
@ -98,8 +153,11 @@ class TestCmd(BaseTestCase):
session.get = MagicMock( session.get = MagicMock(
side_effect=mock_get() side_effect=mock_get()
) )
with self.assertRaisesRegex(Exception, with self.assertRaisesRegex(
'--tenant argument is required'): Exception,
"the --tenant argument or the 'tenant' field "
"in the configuration file is required",
):
ZC._main(['--zuul-url', 'https://fake.zuul', ZC._main(['--zuul-url', 'https://fake.zuul',
'--auth-token', 'aiaiaiai', ] + args) '--auth-token', 'aiaiaiai', ] + args)
session.get = MagicMock( session.get = MagicMock(
@ -480,8 +538,11 @@ class TestCmd(BaseTestCase):
'builds', '--tenant', 'tenant1', '--voting', '--non-voting']) 'builds', '--tenant', 'tenant1', '--voting', '--non-voting'])
with patch('requests.Session') as mock_sesh: with patch('requests.Session') as mock_sesh:
session = mock_sesh.return_value session = mock_sesh.return_value
session.post = MagicMock( session.get = MagicMock(
return_value=FakeRequestResponse(200, {})) side_effect=mock_get(
MagicMock(return_value=FakeRequestResponse(200, []))
)
)
exit_code = ZC._main( exit_code = ZC._main(
['--zuul-url', 'https://fake.zuul', 'builds', ['--zuul-url', 'https://fake.zuul', 'builds',
'--pipeline', 'gate', '--pipeline', 'gate',

View File

@ -80,6 +80,17 @@ class ZuulRESTClient(object):
raise ZuulRESTException( raise ZuulRESTException(
'Unknown error code %s: "%s"' % (req.status_code, e)) 'Unknown error code %s: "%s"' % (req.status_code, e))
def _check_scope(self, tenant):
scope = self.info.get("tenant", None)
if (
(scope is not None)
and (tenant not in [None, ""])
and scope != tenant
):
raise Exception(
"Tenant %s and tenant scope %s do not match" % (tenant, scope)
)
def autohold(self, tenant, project, job, change, ref, def autohold(self, tenant, project, job, change, ref,
reason, count, node_hold_expiration): reason, count, node_hold_expiration):
if not self.auth_token: if not self.auth_token:
@ -91,6 +102,7 @@ class ZuulRESTClient(object):
"ref": ref, "ref": ref,
"node_hold_expiration": node_hold_expiration} "node_hold_expiration": node_hold_expiration}
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'project/%s/autohold' % project suffix = 'project/%s/autohold' % project
else: else:
suffix = 'tenant/%s/project/%s/autohold' % (tenant, project) suffix = 'tenant/%s/project/%s/autohold' % (tenant, project)
@ -103,6 +115,7 @@ class ZuulRESTClient(object):
def autohold_list(self, tenant): def autohold_list(self, tenant):
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'autohold' suffix = 'autohold'
else: else:
suffix = 'tenant/%s/autohold' % tenant suffix = 'tenant/%s/autohold' % tenant
@ -119,6 +132,7 @@ class ZuulRESTClient(object):
if not self.auth_token: if not self.auth_token:
raise Exception('Auth Token required') raise Exception('Auth Token required')
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'autohold/%s' % id suffix = 'autohold/%s' % id
else: else:
suffix = 'tenant/%s/autohold/%s' % (tenant, id) suffix = 'tenant/%s/autohold/%s' % (tenant, id)
@ -132,6 +146,7 @@ class ZuulRESTClient(object):
def autohold_info(self, id, tenant): def autohold_info(self, id, tenant):
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'autohold/%s' % id suffix = 'autohold/%s' % id
else: else:
suffix = 'tenant/%s/autohold/%s' % (tenant, id) suffix = 'tenant/%s/autohold/%s' % (tenant, id)
@ -150,6 +165,7 @@ class ZuulRESTClient(object):
args = {"change": change, args = {"change": change,
"pipeline": pipeline} "pipeline": pipeline}
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'project/%s/enqueue' % project suffix = 'project/%s/enqueue' % project
else: else:
suffix = 'tenant/%s/project/%s/enqueue' % (tenant, project) suffix = 'tenant/%s/project/%s/enqueue' % (tenant, project)
@ -168,6 +184,7 @@ class ZuulRESTClient(object):
"newrev": newrev, "newrev": newrev,
"pipeline": pipeline} "pipeline": pipeline}
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'project/%s/enqueue' % project suffix = 'project/%s/enqueue' % project
else: else:
suffix = 'tenant/%s/project/%s/enqueue' % (tenant, project) suffix = 'tenant/%s/project/%s/enqueue' % (tenant, project)
@ -189,6 +206,7 @@ class ZuulRESTClient(object):
else: else:
raise Exception('need change OR ref') raise Exception('need change OR ref')
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'project/%s/dequeue' % project suffix = 'project/%s/dequeue' % project
else: else:
suffix = 'tenant/%s/project/%s/dequeue' % (tenant, project) suffix = 'tenant/%s/project/%s/dequeue' % (tenant, project)
@ -205,6 +223,7 @@ class ZuulRESTClient(object):
args = {'pipeline': pipeline, args = {'pipeline': pipeline,
'changes': change_ids} 'changes': change_ids}
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'promote' suffix = 'promote'
else: else:
suffix = 'tenant/%s/promote' % tenant suffix = 'tenant/%s/promote' % tenant
@ -217,6 +236,7 @@ class ZuulRESTClient(object):
def get_key(self, tenant, project): def get_key(self, tenant, project):
if self.info.get('tenant'): if self.info.get('tenant'):
self._check_scope(tenant)
suffix = 'key/%s.pub' % project suffix = 'key/%s.pub' % project
else: else:
suffix = 'tenant/%s/key/%s.pub' % (tenant, project) suffix = 'tenant/%s/key/%s.pub' % (tenant, project)
@ -241,9 +261,12 @@ class ZuulRESTClient(object):
params['limit'] = 50 params['limit'] = 50
if 'skip' not in params: if 'skip' not in params:
params['skip'] = 0 params['skip'] = 0
url = urllib.parse.urljoin( if self.info.get("tenant"):
self.base_url, self._check_scope(tenant)
'tenant/%s/builds' % tenant) suffix = "builds"
else:
suffix = "tenant/%s/builds" % tenant
url = urllib.parse.urljoin(self.base_url, suffix)
req = self.session.get(url, params=kwargs) req = self.session.get(url, params=kwargs)
self._check_request_status(req) self._check_request_status(req)
return req.json() return req.json()

View File

@ -173,16 +173,19 @@ class ZuulClient():
sys.exit(1) sys.exit(1)
def _check_tenant_scope(self, client): def _check_tenant_scope(self, client):
tenant_scope = client.info.get('tenant', None) tenant_scope = client.info.get("tenant", None)
if self.args.tenant != '': tenant = self.tenant()
if tenant_scope is not None and tenant_scope != self.args.tenant: if tenant != "":
if tenant_scope is not None and tenant_scope != tenant:
raise ArgumentException( raise ArgumentException(
'Error: Zuul API URL %s is ' "Error: Zuul API URL %s is "
'scoped to tenant "%s"' % (client.base_url, tenant_scope)) 'scoped to tenant "%s"' % (client.base_url, tenant_scope)
)
else: else:
if tenant_scope is None: if tenant_scope is None:
raise ArgumentException( raise ArgumentException(
"Error: the --tenant argument is required" "Error: the --tenant argument or the 'tenant' "
"field in the configuration file is required"
) )
def add_autohold_subparser(self, subparsers): def add_autohold_subparser(self, subparsers):
@ -223,7 +226,7 @@ class ZuulClient():
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
r = client.autohold( r = client.autohold(
tenant=self.args.tenant, tenant=self.tenant(),
project=self.args.project, project=self.args.project,
job=self.args.job, job=self.args.job,
change=self.args.change, change=self.args.change,
@ -246,7 +249,7 @@ class ZuulClient():
def autohold_delete(self): def autohold_delete(self):
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
return client.autohold_delete(self.args.id, self.args.tenant) return client.autohold_delete(self.args.id, self.tenant())
def add_autohold_info_subparser(self, subparsers): def add_autohold_info_subparser(self, subparsers):
cmd_autohold_info = subparsers.add_parser( cmd_autohold_info = subparsers.add_parser(
@ -261,7 +264,7 @@ class ZuulClient():
def autohold_info(self): def autohold_info(self):
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
request = client.autohold_info(self.args.id, self.args.tenant) request = client.autohold_info(self.args.id, self.tenant())
if not request: if not request:
print("Autohold request not found") print("Autohold request not found")
@ -292,7 +295,7 @@ class ZuulClient():
def autohold_list(self): def autohold_list(self):
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
autohold_requests = client.autohold_list(tenant=self.args.tenant) autohold_requests = client.autohold_list(tenant=self.tenant())
if not autohold_requests: if not autohold_requests:
print("No autohold requests found") print("No autohold requests found")
@ -335,7 +338,7 @@ class ZuulClient():
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
r = client.enqueue( r = client.enqueue(
tenant=self.args.tenant, tenant=self.tenant(),
pipeline=self.args.pipeline, pipeline=self.args.pipeline,
project=self.args.project, project=self.args.project,
change=self.args.change) change=self.args.change)
@ -370,7 +373,7 @@ class ZuulClient():
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
r = client.enqueue_ref( r = client.enqueue_ref(
tenant=self.args.tenant, tenant=self.tenant(),
pipeline=self.args.pipeline, pipeline=self.args.pipeline,
project=self.args.project, project=self.args.project,
ref=self.args.ref, ref=self.args.ref,
@ -399,7 +402,7 @@ class ZuulClient():
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
r = client.dequeue( r = client.dequeue(
tenant=self.args.tenant, tenant=self.tenant(),
pipeline=self.args.pipeline, pipeline=self.args.pipeline,
project=self.args.project, project=self.args.project,
change=self.args.change, change=self.args.change,
@ -422,7 +425,7 @@ class ZuulClient():
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
r = client.promote( r = client.promote(
tenant=self.args.tenant, tenant=self.tenant(),
pipeline=self.args.pipeline, pipeline=self.args.pipeline,
change_ids=self.args.changes) change_ids=self.args.changes)
return r return r
@ -464,6 +467,22 @@ class ZuulClient():
client = ZuulRESTClient(server, verify, auth_token) client = ZuulRESTClient(server, verify, auth_token)
return client return client
def tenant(self):
if self.args.tenant == "":
if self.config is not None:
config_tenant = ""
conf_sections = self.config.sections()
if (
self.args.zuul_config
and self.args.zuul_config in conf_sections
):
zuul_conf = self.args.zuul_config
config_tenant = get_default(
self.config, zuul_conf, "tenant", ""
)
return config_tenant
return self.args.tenant
def add_encrypt_subparser(self, subparsers): def add_encrypt_subparser(self, subparsers):
cmd_encrypt = subparsers.add_parser( cmd_encrypt = subparsers.add_parser(
'encrypt', help='Encrypt a secret to be used in a project\'s jobs') 'encrypt', help='Encrypt a secret to be used in a project\'s jobs')
@ -534,7 +553,7 @@ class ZuulClient():
else: else:
client = self.get_client() client = self.get_client()
self._check_tenant_scope(client) self._check_tenant_scope(client)
key = client.get_key(self.args.tenant, self.args.project) key = client.get_key(self.tenant(), self.args.project)
pubkey_file.write(str.encode(key)) pubkey_file.write(str.encode(key))
pubkey_file.close() pubkey_file.close()
self.log.debug('Calling openssl') self.log.debug('Calling openssl')
@ -654,7 +673,7 @@ class ZuulClient():
if self.args.held: if self.args.held:
filters['held'] = True filters['held'] = True
client = self.get_client() client = self.get_client()
builds = client.builds(tenant=self.args.tenant, **filters) builds = client.builds(tenant=self.tenant(), **filters)
table = prettytable.PrettyTable( table = prettytable.PrettyTable(
field_names=[ field_names=[
'ID', 'Job', 'Project', 'Branch', 'Pipeline', 'Change or Ref', 'ID', 'Job', 'Project', 'Branch', 'Pipeline', 'Change or Ref',