diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index be43a96..a4f0f49 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -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: diff --git a/doc/source/examples/.zuul.conf b/doc/source/examples/.zuul.conf index 31b1994..8366f37 100644 --- a/doc/source/examples/.zuul.conf +++ b/doc/source/examples/.zuul.conf @@ -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 diff --git a/releasenotes/notes/tenant_config_option-becaa20d07bdf14d.yaml b/releasenotes/notes/tenant_config_option-becaa20d07bdf14d.yaml new file mode 100644 index 0000000..85fd98d --- /dev/null +++ b/releasenotes/notes/tenant_config_option-becaa20d07bdf14d.yaml @@ -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. \ No newline at end of file diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 5a9bbb5..febc633 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -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' diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 92fc54b..70ecd85 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -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', diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index 3c5b910..057ba51 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -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() diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 70475dc..eb3619c 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -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',