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
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.
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:

View File

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

View File

@ -63,6 +63,61 @@ class TestCmd(BaseTestCase):
'--reason', 'some reason',
'--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):
"""Test the right uses of --tenant"""
ZC = ZuulClient()
@ -98,8 +153,11 @@ class TestCmd(BaseTestCase):
session.get = MagicMock(
side_effect=mock_get()
)
with self.assertRaisesRegex(Exception,
'--tenant argument is required'):
with self.assertRaisesRegex(
Exception,
"the --tenant argument or the 'tenant' field "
"in the configuration file is required",
):
ZC._main(['--zuul-url', 'https://fake.zuul',
'--auth-token', 'aiaiaiai', ] + args)
session.get = MagicMock(
@ -480,8 +538,11 @@ class TestCmd(BaseTestCase):
'builds', '--tenant', 'tenant1', '--voting', '--non-voting'])
with patch('requests.Session') as mock_sesh:
session = mock_sesh.return_value
session.post = MagicMock(
return_value=FakeRequestResponse(200, {}))
session.get = MagicMock(
side_effect=mock_get(
MagicMock(return_value=FakeRequestResponse(200, []))
)
)
exit_code = ZC._main(
['--zuul-url', 'https://fake.zuul', 'builds',
'--pipeline', 'gate',

View File

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

View File

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