[libvirt] [jenkins-ci PATCH 00/20] quayadmin: Introduce Quay API client

Dan started building this a while ago, and I finally managed to find some time to take it from a PoC to something that's actually useful for our day to day operations. There's still plenty of work to be done for sure, but it's already at the point where I can at least partially script the process of refreshing container images, which is something that up until now had to be done slowly and painfully through the Web interface. I will probably spend some more time on it in a couple of weeks, after I've used it to refresh images right before the 5.6.0 release and inevitably spotted a bunch of issues while doing so O:-) Andrea Bolognani (20): quayadmin: Import initial implementation quayadmin: Fix argument parsing for delete-repo quayadmin: Fix quiet mode for show-repo quayadmin: Tweak show-repo output format quayadmin: Tweak list-repos output format quayadmin: Build endpoint, params and payload separately quayadmin: Implement debug mode quayadmin: Remove clientid and clientsecret quayamdin: Introduce configuration object quayadmin: Read configuration from file quayadmin: Add support for the PUT method quayadmin: Add list-triggers command quayadmin: Add show-trigger command quayadmin: Add activate-trigger command quayadmin: Add list-builds command quayadmin: Add show-build command quayadmin: Add list-tags command quayadmin: Add show-tag command quayadmin: Add create-tag command quayadmin: Add delete-tag command guests/quayadmin | 563 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100755 guests/quayadmin -- 2.21.0

This code was written by Daniel P. Berrangé, and contains the scaffolding necessary to call Quay APIs as well as the implementation of a few commands. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 198 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100755 guests/quayadmin diff --git a/guests/quayadmin b/guests/quayadmin new file mode 100755 index 0000000..31ea929 --- /dev/null +++ b/guests/quayadmin @@ -0,0 +1,198 @@ +#!/usr/bin/python3 +# -*- python -*- +# +# quayadmin - client for quay.io +# +# Copyright (C) 2019 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <https://www.gnu.org/licenses/>. + +import argparse +import requests +import sys + +baseurl = "https://quay.io/api/v1" +clientid = "xxx" +clientsecret = "xxx" +token= "xxx" + + +def request(endpoint, method, payload=None, params=None): + url = baseurl + endpoint + + headers = { + "Authorization": "Bearer {}".format(token) + } + + return method(url, headers=headers, json=payload, params=params) + + +def get(endpoint, params=None): + return request(endpoint, method=requests.get, params=params) + + +def delete(endpoint, payload=None): + return request(endpoint, method=requests.delete, payload=payload) + + +def post(endpoint, payload=None): + return request(endpoint, method=requests.post, payload=payload) + + +def has_error(quiet, res, expected, message): + if res.status_code == expected: + return False + + if res.status_code >= 400 and res.status_code < 500: + info = res.json() + err = info["error_message"] + else: + err = res.content + + if not quiet: + print("{}: {} ({})".format(message, err, res.status_code)) + return True + + +def run_list_repos(args): + res = get("/repository", params={"namespace": args.namespace}) + + if has_error(args.quiet, res, 200, "Cannot list repositories"): + return 1 + + info = res.json() + for repo in info["repositories"]: + print ("{}/{}".format(repo["namespace"], repo["name"])) + + +def run_show_repo(args): + res = get("/repository/{}/{}".format(args.namespace, args.repo)) + + if has_error(args.quiet, res, 200, "Cannot query repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + info = res.json() + if not args.quiet: + print("{}/{}: {}".format(args.namespace, args.repo, info["description"])) + + +def run_create_repo(args): + res = post("/repository", payload={ + "repo_kind": "image", + "namespace": args.namespace, + "visibility": "public", + "repository": args.repo, + "description": args.desc, + }) + + if has_error(args.quiet, res, 201, "Cannot create repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + if not args.quiet: + print("Repository {}/{} created".format(args.namespace, args.repo)) + + +def run_delete_repo(args): + res = delete("/repository/{}/{}".format(args.namespace, args.repo)) + + if has_error(args.quiet, res, 204, "Cannot delete repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + if not args.quiet: + print("Repository {}/{} deleted".format(args.namespace, args.repo)) + + +def add_arg_namespace(parser): + parser.add_argument("namespace", help="Organization or user name") + + +def add_arg_repo(parser): + parser.add_argument("repo", help="Repository name") + + +def add_arg_desc(parser): + parser.add_argument("desc", help="Repository description") + + +def build_parser_list_repos(subparser): + parser = subparser.add_parser("list-repos", help="List container repositories") + + parser.set_defaults(func=run_list_repos) + + add_arg_namespace(parser) + + +def build_parser_create_repo(subparser): + parser = subparser.add_parser("create-repo", help="Create a new repository") + + parser.set_defaults(func=run_create_repo) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_desc(parser) + + +def build_parser_show_repo(subparser): + parser = subparser.add_parser("show-repo", help="Show repository info") + + parser.set_defaults(func=run_show_repo) + + add_arg_namespace(parser) + add_arg_repo(parser) + + +def build_parser_delete_repo(subparser): + parser = subparser.add_parser("delete-repo", help="Delete an existing repository") + + parser.set_defaults(func=run_create_repo) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_desc(parser) + + +def build_parser(): + parser = argparse.ArgumentParser( + description="quay.io client admin tool" + ) + + parser.add_argument("--debug", '-d', action="store_true", help="Print debugging information") + parser.add_argument("--quiet", '-q', action="store_true", help="Display minimal information") + + subparser = parser.add_subparsers(metavar="COMMAND") + subparser.required = True + + build_parser_list_repos(subparser) + build_parser_show_repo(subparser) + build_parser_create_repo(subparser) + build_parser_delete_repo(subparser) + + return parser + +def main(): + parser = build_parser() + args = parser.parse_args() + + try: + res = args.func(args) + sys.exit(res) + except Exception as ex: + sys.stderr.write("{}: {}\n".format(sys.argv[0], ex)) + sys.exit(1) + +if __name__ == "__main__": + main() -- 2.21.0

On Wed, Jul 17, 2019 at 01:53:50PM +0200, Andrea Bolognani wrote:
This code was written by Daniel P. Berrangé, and contains the scaffolding necessary to call Quay APIs as well as the implementation of a few commands.
Per our Governance document: https://libvirt.org/governance.html#contributors we're going to need him to demonstrate compliance with the Developer Certificate of Origin: https://developercertificate.org/ by providing a Signed-off. Jano
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 198 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100755 guests/quayadmin

On Wed, Jul 17, 2019 at 01:53:50PM +0200, Andrea Bolognani wrote:
This code was written by Daniel P. Berrangé, and contains the scaffolding necessary to call Quay APIs as well as the implementation of a few commands.
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 198 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100755 guests/quayadmin
Since this is my code Signed-off-by: Daniel P. Berrangé <berrange@redhat.com>
diff --git a/guests/quayadmin b/guests/quayadmin new file mode 100755 index 0000000..31ea929 --- /dev/null +++ b/guests/quayadmin @@ -0,0 +1,198 @@ +#!/usr/bin/python3 +# -*- python -*- +# +# quayadmin - client for quay.io +# +# Copyright (C) 2019 Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <https://www.gnu.org/licenses/>. + +import argparse +import requests +import sys + +baseurl = "https://quay.io/api/v1" +clientid = "xxx" +clientsecret = "xxx" +token= "xxx"
/me tries to remember if I put the "xxx" in there myself before sending you the code, or if you have grabbed my token before scrubbing it yourself :-P Regards, Daniel -- |: https://berrange.com -o- https://www.flickr.com/photos/dberrange :| |: https://libvirt.org -o- https://fstop138.berrange.com :| |: https://entangle-photo.org -o- https://www.instagram.com/dberrange :|

It was clearly copied from create-repo and not cleaned up sufficiently afterwards. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index 31ea929..5dc5eff 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -158,11 +158,10 @@ def build_parser_show_repo(subparser): def build_parser_delete_repo(subparser): parser = subparser.add_parser("delete-repo", help="Delete an existing repository") - parser.set_defaults(func=run_create_repo) + parser.set_defaults(func=run_delete_repo) add_arg_namespace(parser) add_arg_repo(parser) - add_arg_desc(parser) def build_parser(): -- 2.21.0

On Wed, Jul 17, 2019 at 01:53:51PM +0200, Andrea Bolognani wrote:
It was clearly copied from create-repo and not cleaned up sufficiently afterwards.
This is fixing lines introduced in the previous patch, why not just squash it? Jano
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-)

On Wed, 2019-07-17 at 14:11 +0200, Ján Tomko wrote:
On Wed, Jul 17, 2019 at 01:53:51PM +0200, Andrea Bolognani wrote:
It was clearly copied from create-repo and not cleaned up sufficiently afterwards.
This is fixing lines introduced in the previous patch, why not just squash it?
Because the previous commit is 100% Dan's code (which he now provided a S-o-b for) while this is 100% my code, and I thought it would be a good idea to maintain a clear separation between the two. -- Andrea Bolognani / Red Hat / Virtualization

Since the user is explicity asking for information to be displayed, we should present it to them whether or not quiet mode is enabled. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index 5dc5eff..980fc1b 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -84,8 +84,8 @@ def run_show_repo(args): return 1 info = res.json() - if not args.quiet: - print("{}/{}: {}".format(args.namespace, args.repo, info["description"])) + + print("{}/{}: {}".format(args.namespace, args.repo, info["description"])) def run_create_repo(args): -- 2.21.0

On Wed, Jul 17, 2019 at 01:53:52PM +0200, Andrea Bolognani wrote:
Since the user is explicity asking for information to be displayed, we should present it to them whether or not quiet mode is enabled.
The reason why I honoured the "quiet" flag in this command was because it serves as a "does the repo exist" check, and when used for that purpose avoids need to redirect stdout to /dev/null.
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/guests/quayadmin b/guests/quayadmin index 5dc5eff..980fc1b 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -84,8 +84,8 @@ def run_show_repo(args): return 1
info = res.json() - if not args.quiet: - print("{}/{}: {}".format(args.namespace, args.repo, info["description"])) + + print("{}/{}: {}".format(args.namespace, args.repo, info["description"]))
Regards, Daniel -- |: https://berrange.com -o- https://www.flickr.com/photos/dberrange :| |: https://libvirt.org -o- https://fstop138.berrange.com :| |: https://entangle-photo.org -o- https://www.instagram.com/dberrange :|

On Wed, 2019-07-17 at 13:00 +0100, Daniel P. Berrangé wrote:
On Wed, Jul 17, 2019 at 01:53:52PM +0200, Andrea Bolognani wrote:
Since the user is explicity asking for information to be displayed, we should present it to them whether or not quiet mode is enabled.
The reason why I honoured the "quiet" flag in this command was because it serves as a "does the repo exist" check, and when used for that purpose avoids need to redirect stdout to /dev/null.
Okay, that makes sense. I'll drop this commit and make sure all other show-* commands also behave this way. -- Andrea Bolognani / Red Hat / Virtualization

We're going to introduce several other show-* commands later, and we want their output to be more or less consistent, so start by tweaking the existing command. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/guests/quayadmin b/guests/quayadmin index 980fc1b..cec0e64 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -85,7 +85,10 @@ def run_show_repo(args): info = res.json() - print("{}/{}: {}".format(args.namespace, args.repo, info["description"])) + print("repo:") + print(" namespace: {}".format(args.namespace)) + print(" repo: {}".format(args.repo)) + print(" description: {}".format(info["description"])) def run_create_repo(args): -- 2.21.0

We're going to introduce several other list-* commands later, and we want their output to be more or less consistent, so start by tweaking the existing command. More specifically, we want the output of each list-* command to be such that you can take one of the lines, append it to the arguments you just used and by doing so obtain a valid list of arguments for the corresponding show-* command, eg. $ quayadmin list-repos libvirt buildenv-debian-10 $ quayadmin show-repo libvirt buildenv-debian-10 repo: namespace: libvirt repo: buildenv-debian-10 $ Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guests/quayadmin b/guests/quayadmin index cec0e64..ed10bfc 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -73,7 +73,7 @@ def run_list_repos(args): info = res.json() for repo in info["repositories"]: - print ("{}/{}".format(repo["namespace"], repo["name"])) + print ("{}".format(repo["name"])) def run_show_repo(args): -- 2.21.0

Instead of building all arguments for a request at the same time as the request itself is prepared, build them beforehand and then pass them to the corresponding function. This makes the code more readable, especially when complex params or long endpoints are involved. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index ed10bfc..e2b34e0 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -66,18 +66,26 @@ def has_error(quiet, res, expected, message): def run_list_repos(args): - res = get("/repository", params={"namespace": args.namespace}) + endpoint = "/repository" + params = { + "namespace": args.namespace, + } + + res = get(endpoint, params=params) if has_error(args.quiet, res, 200, "Cannot list repositories"): return 1 info = res.json() + for repo in info["repositories"]: print ("{}".format(repo["name"])) def run_show_repo(args): - res = get("/repository/{}/{}".format(args.namespace, args.repo)) + endpoint = "/repository/{}/{}".format(args.namespace, args.repo) + + res = get(endpoint) if has_error(args.quiet, res, 200, "Cannot query repository {}/{}" .format(args.namespace, args.repo)): @@ -92,13 +100,16 @@ def run_show_repo(args): def run_create_repo(args): - res = post("/repository", payload={ + endpoint = "/repository" + payload = { "repo_kind": "image", "namespace": args.namespace, "visibility": "public", "repository": args.repo, "description": args.desc, - }) + } + + res = post(endpoint, payload=payload) if has_error(args.quiet, res, 201, "Cannot create repository {}/{}" .format(args.namespace, args.repo)): @@ -109,7 +120,9 @@ def run_create_repo(args): def run_delete_repo(args): - res = delete("/repository/{}/{}".format(args.namespace, args.repo)) + endpoint = "/repository/{}/{}".format(args.namespace, args.repo) + + res = delete(endpoint) if has_error(args.quiet, res, 204, "Cannot delete repository {}/{}" .format(args.namespace, args.repo)): -- 2.21.0

While a command line parameter for enabling debug mode has been defined, it's not currently used in any form or shape. Make it so enabling debug mode actually does something useful, that is, prints the most important parts of the conversation happening between the client and the server. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index e2b34e0..c40f71f 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -19,6 +19,7 @@ # with this program. If not, see <https://www.gnu.org/licenses/>. import argparse +import pprint import requests import sys @@ -28,26 +29,42 @@ clientsecret = "xxx" token= "xxx" -def request(endpoint, method, payload=None, params=None): +def request(endpoint, method, payload=None, params=None, debug=False): url = baseurl + endpoint headers = { "Authorization": "Bearer {}".format(token) } - return method(url, headers=headers, json=payload, params=params) + if debug: + print("=> {") + print(" url={}".format(url)) + print(" params={}".format(pprint.pformat(params))) + print(" payload={}".format(pprint.pformat(payload))) + print("}") + res = method(url, headers=headers, json=payload, params=params) -def get(endpoint, params=None): - return request(endpoint, method=requests.get, params=params) + if debug: + print("<= {") + print(" status_code={}".format(res.status_code)) + if res.text is not None and len(res.text) > 0: + print(" json={}".format(pprint.pformat(res.json()))) + print("}") + return res -def delete(endpoint, payload=None): - return request(endpoint, method=requests.delete, payload=payload) +def get(endpoint, params=None, debug=False): + return request(endpoint, method=requests.get, params=params, debug=debug) -def post(endpoint, payload=None): - return request(endpoint, method=requests.post, payload=payload) + +def delete(endpoint, payload=None, debug=False): + return request(endpoint, method=requests.delete, payload=payload, debug=debug) + + +def post(endpoint, payload=None, debug=False): + return request(endpoint, method=requests.post, payload=payload, debug=debug) def has_error(quiet, res, expected, message): @@ -71,7 +88,7 @@ def run_list_repos(args): "namespace": args.namespace, } - res = get(endpoint, params=params) + res = get(endpoint, params=params, debug=args.debug) if has_error(args.quiet, res, 200, "Cannot list repositories"): return 1 @@ -85,7 +102,7 @@ def run_list_repos(args): def run_show_repo(args): endpoint = "/repository/{}/{}".format(args.namespace, args.repo) - res = get(endpoint) + res = get(endpoint, debug=args.debug) if has_error(args.quiet, res, 200, "Cannot query repository {}/{}" .format(args.namespace, args.repo)): @@ -109,7 +126,7 @@ def run_create_repo(args): "description": args.desc, } - res = post(endpoint, payload=payload) + res = post(endpoint, payload=payload, debug=args.debug) if has_error(args.quiet, res, 201, "Cannot create repository {}/{}" .format(args.namespace, args.repo)): @@ -122,7 +139,7 @@ def run_create_repo(args): def run_delete_repo(args): endpoint = "/repository/{}/{}".format(args.namespace, args.repo) - res = delete(endpoint) + res = delete(endpoint, debug=args.debug) if has_error(args.quiet, res, 204, "Cannot delete repository {}/{}" .format(args.namespace, args.repo)): -- 2.21.0

They're not currently used, and don't look like they're actually necessary to use the Quay API. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 2 -- 1 file changed, 2 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index c40f71f..7c503a4 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -24,8 +24,6 @@ import requests import sys baseurl = "https://quay.io/api/v1" -clientid = "xxx" -clientsecret = "xxx" token= "xxx" -- 2.21.0

Instead of storing the client configuration in global variables, create a single configuration object to store all of them and pass around for functions to use. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index 7c503a4..25128e5 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -23,15 +23,20 @@ import pprint import requests import sys -baseurl = "https://quay.io/api/v1" -token= "xxx" +def get_config(): + config = { + "baseurl": "https://quay.io/api/v1", + "token": "xxx", + } + + return config -def request(endpoint, method, payload=None, params=None, debug=False): - url = baseurl + endpoint +def request(config, endpoint, method, payload=None, params=None, debug=False): + url = config["baseurl"] + endpoint headers = { - "Authorization": "Bearer {}".format(token) + "Authorization": "Bearer {}".format(config["token"]) } if debug: @@ -53,16 +58,16 @@ def request(endpoint, method, payload=None, params=None, debug=False): return res -def get(endpoint, params=None, debug=False): - return request(endpoint, method=requests.get, params=params, debug=debug) +def get(config, endpoint, params=None, debug=False): + return request(config, endpoint, method=requests.get, params=params, debug=debug) -def delete(endpoint, payload=None, debug=False): - return request(endpoint, method=requests.delete, payload=payload, debug=debug) +def delete(config, endpoint, payload=None, debug=False): + return request(config, endpoint, method=requests.delete, payload=payload, debug=debug) -def post(endpoint, payload=None, debug=False): - return request(endpoint, method=requests.post, payload=payload, debug=debug) +def post(config, endpoint, payload=None, debug=False): + return request(config, endpoint, method=requests.post, payload=payload, debug=debug) def has_error(quiet, res, expected, message): @@ -80,13 +85,13 @@ def has_error(quiet, res, expected, message): return True -def run_list_repos(args): +def run_list_repos(config, args): endpoint = "/repository" params = { "namespace": args.namespace, } - res = get(endpoint, params=params, debug=args.debug) + res = get(config, endpoint, params=params, debug=args.debug) if has_error(args.quiet, res, 200, "Cannot list repositories"): return 1 @@ -97,10 +102,10 @@ def run_list_repos(args): print ("{}".format(repo["name"])) -def run_show_repo(args): +def run_show_repo(config, args): endpoint = "/repository/{}/{}".format(args.namespace, args.repo) - res = get(endpoint, debug=args.debug) + res = get(config, endpoint, debug=args.debug) if has_error(args.quiet, res, 200, "Cannot query repository {}/{}" .format(args.namespace, args.repo)): @@ -114,7 +119,7 @@ def run_show_repo(args): print(" description: {}".format(info["description"])) -def run_create_repo(args): +def run_create_repo(config, args): endpoint = "/repository" payload = { "repo_kind": "image", @@ -124,7 +129,7 @@ def run_create_repo(args): "description": args.desc, } - res = post(endpoint, payload=payload, debug=args.debug) + res = post(config, endpoint, payload=payload, debug=args.debug) if has_error(args.quiet, res, 201, "Cannot create repository {}/{}" .format(args.namespace, args.repo)): @@ -134,10 +139,10 @@ def run_create_repo(args): print("Repository {}/{} created".format(args.namespace, args.repo)) -def run_delete_repo(args): +def run_delete_repo(config, args): endpoint = "/repository/{}/{}".format(args.namespace, args.repo) - res = delete(endpoint, debug=args.debug) + res = delete(config, endpoint, debug=args.debug) if has_error(args.quiet, res, 204, "Cannot delete repository {}/{}" .format(args.namespace, args.repo)): @@ -218,7 +223,7 @@ def main(): args = parser.parse_args() try: - res = args.func(args) + res = args.func(get_config(), args) sys.exit(res) except Exception as ex: sys.stderr.write("{}: {}\n".format(sys.argv[0], ex)) -- 2.21.0

s/amd/adm/ Jano On Wed, Jul 17, 2019 at 01:53:58PM +0200, Andrea Bolognani wrote:
Instead of storing the client configuration in global variables, create a single configuration object to store all of them and pass around for functions to use.
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-)

We don't want sensitive information such as the API token to be stored into the script, both because it could lead to them being leaked by mistake and because it makes it needlessly complicated for users to take advantage of the tool. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/guests/quayadmin b/guests/quayadmin index 25128e5..4e60653 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -19,15 +19,32 @@ # with this program. If not, see <https://www.gnu.org/licenses/>. import argparse +import configparser +import os import pprint import requests import sys def get_config(): - config = { - "baseurl": "https://quay.io/api/v1", - "token": "xxx", - } + try: + path = os.environ["XDG_CONFIG_HOME"] + except KeyError: + path = os.path.join(os.environ["HOME"], ".config") + path = os.path.join(os.path.join(path, "quayadmin"), "config.ini") + + try: + parser = configparser.ConfigParser() + parser.read_file(open(path)) + except Exception as ex: + raise Exception("Cannot load config: {}".format(ex)) + + try: + config = { + "baseurl": "https://quay.io/api/v1", + "token": parser["DEFAULT"]["token"], + } + except KeyError: + raise Exception("Token not found in {}".format(path)) return config -- 2.21.0

On Wed, Jul 17, 2019 at 01:53:59PM +0200, Andrea Bolognani wrote:
We don't want sensitive information such as the API token to be stored into the script, both because it could lead to them being leaked by mistake and because it makes it needlessly complicated for users to take advantage of the tool.
We arguably don't want the token stored cleartext in a config file either. How about making use of the system keyring - there's a python module that looks to make this fairly easy https://pypi.org/project/keyring/
Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/guests/quayadmin b/guests/quayadmin index 25128e5..4e60653 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -19,15 +19,32 @@ # with this program. If not, see <https://www.gnu.org/licenses/>.
import argparse +import configparser +import os import pprint import requests import sys
def get_config(): - config = { - "baseurl": "https://quay.io/api/v1", - "token": "xxx", - } + try: + path = os.environ["XDG_CONFIG_HOME"] + except KeyError: + path = os.path.join(os.environ["HOME"], ".config") + path = os.path.join(os.path.join(path, "quayadmin"), "config.ini") + + try: + parser = configparser.ConfigParser() + parser.read_file(open(path)) + except Exception as ex: + raise Exception("Cannot load config: {}".format(ex)) + + try: + config = { + "baseurl": "https://quay.io/api/v1", + "token": parser["DEFAULT"]["token"], + } + except KeyError: + raise Exception("Token not found in {}".format(path))
return config
-- 2.21.0
-- libvir-list mailing list libvir-list@redhat.com https://www.redhat.com/mailman/listinfo/libvir-list
Regards, Daniel -- |: https://berrange.com -o- https://www.flickr.com/photos/dberrange :| |: https://libvirt.org -o- https://fstop138.berrange.com :| |: https://entangle-photo.org -o- https://www.instagram.com/dberrange :|

On Wed, 2019-07-17 at 13:03 +0100, Daniel P. Berrangé wrote:
On Wed, Jul 17, 2019 at 01:53:59PM +0200, Andrea Bolognani wrote:
We don't want sensitive information such as the API token to be stored into the script, both because it could lead to them being leaked by mistake and because it makes it needlessly complicated for users to take advantage of the tool.
We arguably don't want the token stored cleartext in a config file either. How about making use of the system keyring - there's a python module that looks to make this fairly easy
Sounds good as a follow-up improvement[1], but since clearly neither of us has a ton of time to dedicate to this specific script I'd rather merge the Good Enough™ solution for the time being instead of blocking the whole thing on keyring integration. [1] I wonder if I can manage to integrate it with my existing pass(1) setup? That's be pretty neat! -- Andrea Bolognani / Red Hat / Virtualization

We're going to need it for some specific Quay APIs later on. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index 4e60653..9f1b7d4 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -87,6 +87,10 @@ def post(config, endpoint, payload=None, debug=False): return request(config, endpoint, method=requests.post, payload=payload, debug=debug) +def put(config, endpoint, payload=None, debug=False): + return request(config, endpoint, method=requests.put, payload=payload, debug=debug) + + def has_error(quiet, res, expected, message): if res.status_code == expected: return False -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index 9f1b7d4..af324a7 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -173,6 +173,22 @@ def run_delete_repo(config, args): print("Repository {}/{} deleted".format(args.namespace, args.repo)) +def run_list_triggers(config, args): + endpoint = "/repository/{}/{}/trigger".format(args.namespace, args.repo) + + res = get(config, endpoint, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot list triggers for repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + info = res.json() + + for trigger in info["triggers"]: + print(trigger["id"]) + + def add_arg_namespace(parser): parser.add_argument("namespace", help="Organization or user name") @@ -221,6 +237,15 @@ def build_parser_delete_repo(subparser): add_arg_repo(parser) +def build_parser_list_triggers(subparser): + parser = subparser.add_parser("list-triggers", help="List repository triggers") + + parser.set_defaults(func=run_list_triggers) + + add_arg_namespace(parser) + add_arg_repo(parser) + + def build_parser(): parser = argparse.ArgumentParser( description="quay.io client admin tool" @@ -237,6 +262,8 @@ def build_parser(): build_parser_create_repo(subparser) build_parser_delete_repo(subparser) + build_parser_list_triggers(subparser) + return parser def main(): -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index af324a7..de88c55 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -189,6 +189,28 @@ def run_list_triggers(config, args): print(trigger["id"]) +def run_show_trigger(config, args): + endpoint = "/repository/{}/{}/trigger/{}".format(args.namespace, + args.repo, args.trigger) + + res = get(config, endpoint, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot query trigger {} for repository {}/{}" + .format(args.trigger, args.namespace, args.repo)): + return 1 + + info = res.json() + + print("trigger:") + print(" namespace: {}".format(args.namespace)) + print(" repo: {}".format(args.repo)) + print(" id: {}".format(args.trigger)) + print(" source_repo: {}".format(info["config"]["build_source"])) + print(" source_path: {}".format(info["config"]["dockerfile_path"])) + print(" activatable: {}".format(info["can_invoke"])) + + def add_arg_namespace(parser): parser.add_argument("namespace", help="Organization or user name") @@ -201,6 +223,10 @@ def add_arg_desc(parser): parser.add_argument("desc", help="Repository description") +def add_arg_trigger(parser): + parser.add_argument("trigger", help="Trigger ID") + + def build_parser_list_repos(subparser): parser = subparser.add_parser("list-repos", help="List container repositories") @@ -246,6 +272,16 @@ def build_parser_list_triggers(subparser): add_arg_repo(parser) +def build_parser_show_trigger(subparser): + parser = subparser.add_parser("show-trigger", help="Show trigger information") + + parser.set_defaults(func=run_show_trigger) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_trigger(parser) + + def build_parser(): parser = argparse.ArgumentParser( description="quay.io client admin tool" @@ -263,6 +299,7 @@ def build_parser(): build_parser_delete_repo(subparser) build_parser_list_triggers(subparser) + build_parser_show_trigger(subparser) return parser -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index de88c55..dd612da 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -211,6 +211,28 @@ def run_show_trigger(config, args): print(" activatable: {}".format(info["can_invoke"])) +def run_activate_trigger(config, args): + endpoint = "/repository/{}/{}/trigger/{}/start".format(args.namespace, + args.repo, + args.trigger) + payload = { + "commit_sha": args.commit, + } + + res = post(config, endpoint, payload=payload, debug=args.debug) + + if has_error(args.quiet, res, 201, + "Cannot activate trigger {} for repository {}/{}" + .format(args.trigger, args.namespace, args.repo)): + return 1 + + info = res.json() + + if not args.quiet: + print("Build {} for {}/{} created".format(info["id"], + args.namespace, args.repo)) + + def add_arg_namespace(parser): parser.add_argument("namespace", help="Organization or user name") @@ -227,6 +249,10 @@ def add_arg_trigger(parser): parser.add_argument("trigger", help="Trigger ID") +def add_arg_commit(parser): + parser.add_argument("commit", help="Git commit hash") + + def build_parser_list_repos(subparser): parser = subparser.add_parser("list-repos", help="List container repositories") @@ -282,6 +308,17 @@ def build_parser_show_trigger(subparser): add_arg_trigger(parser) +def build_parser_activate_trigger(subparser): + parser = subparser.add_parser("activate-trigger", help="Start build from trigger") + + parser.set_defaults(func=run_activate_trigger) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_trigger(parser) + add_arg_commit(parser) + + def build_parser(): parser = argparse.ArgumentParser( description="quay.io client admin tool" @@ -300,6 +337,7 @@ def build_parser(): build_parser_list_triggers(subparser) build_parser_show_trigger(subparser) + build_parser_activate_trigger(subparser) return parser -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index dd612da..7cc9b5a 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -173,6 +173,22 @@ def run_delete_repo(config, args): print("Repository {}/{} deleted".format(args.namespace, args.repo)) +def run_list_builds(config, args): + endpoint = "/repository/{}/{}/build".format(args.namespace, args.repo) + + res = get(config, endpoint, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot list builds for repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + info = res.json() + + for build in info["builds"]: + print(build["id"]) + + def run_list_triggers(config, args): endpoint = "/repository/{}/{}/trigger".format(args.namespace, args.repo) @@ -289,6 +305,15 @@ def build_parser_delete_repo(subparser): add_arg_repo(parser) +def build_parser_list_builds(subparser): + parser = subparser.add_parser("list-builds", help="List repository builds") + + parser.set_defaults(func=run_list_builds) + + add_arg_namespace(parser) + add_arg_repo(parser) + + def build_parser_list_triggers(subparser): parser = subparser.add_parser("list-triggers", help="List repository triggers") @@ -335,6 +360,8 @@ def build_parser(): build_parser_create_repo(subparser) build_parser_delete_repo(subparser) + build_parser_list_builds(subparser) + build_parser_list_triggers(subparser) build_parser_show_trigger(subparser) build_parser_activate_trigger(subparser) -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index 7cc9b5a..a537706 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -189,6 +189,27 @@ def run_list_builds(config, args): print(build["id"]) +def run_show_build(config, args): + endpoint = "/repository/{}/{}/build/{}".format(args.namespace, + args.repo, args.build) + + res = get(config, endpoint, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot query build {} for repository {}/{}" + .format(args.build, args.namespace, args.repo)): + return 1 + + info = res.json() + + print("build:") + print(" namespace: {}".format(args.namespace)) + print(" repo: {}".format(args.repo)) + print(" id: {}".format(args.build)) + print(" phase: {}".format(info["phase"])) + print(" started: {}".format(info["started"])) + + def run_list_triggers(config, args): endpoint = "/repository/{}/{}/trigger".format(args.namespace, args.repo) @@ -269,6 +290,10 @@ def add_arg_commit(parser): parser.add_argument("commit", help="Git commit hash") +def add_arg_build(parser): + parser.add_argument("build", help="Build ID") + + def build_parser_list_repos(subparser): parser = subparser.add_parser("list-repos", help="List container repositories") @@ -314,6 +339,16 @@ def build_parser_list_builds(subparser): add_arg_repo(parser) +def build_parser_show_build(subparser): + parser = subparser.add_parser("show-build", help="Show build info") + + parser.set_defaults(func=run_show_build) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_build(parser) + + def build_parser_list_triggers(subparser): parser = subparser.add_parser("list-triggers", help="List repository triggers") @@ -361,6 +396,7 @@ def build_parser(): build_parser_delete_repo(subparser) build_parser_list_builds(subparser) + build_parser_show_build(subparser) build_parser_list_triggers(subparser) build_parser_show_trigger(subparser) -- 2.21.0

The implementation is limited in that it will not work properly if a repository contains more than 100 tags, but that's not really a big concern for us as each repository is only going to contain a single tag (latest). Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index a537706..5481e3f 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -173,6 +173,26 @@ def run_delete_repo(config, args): print("Repository {}/{} deleted".format(args.namespace, args.repo)) +def run_list_tags(config, args): + endpoint = "/repository/{}/{}/tag".format(args.namespace, args.repo) + params = { + "onlyActiveTags": True, + "limit": 100, + } + + res = get(config, endpoint, params=params, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot list tags for repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + info = res.json() + + for tag in info["tags"]: + print ("{}".format(tag["name"])) + + def run_list_builds(config, args): endpoint = "/repository/{}/{}/build".format(args.namespace, args.repo) @@ -330,6 +350,15 @@ def build_parser_delete_repo(subparser): add_arg_repo(parser) +def build_parser_list_tags(subparser): + parser = subparser.add_parser("list-tags", help="List repository tags") + + parser.set_defaults(func=run_list_tags) + + add_arg_namespace(parser) + add_arg_repo(parser) + + def build_parser_list_builds(subparser): parser = subparser.add_parser("list-builds", help="List repository builds") @@ -395,6 +424,8 @@ def build_parser(): build_parser_create_repo(subparser) build_parser_delete_repo(subparser) + build_parser_list_tags(subparser) + build_parser_list_builds(subparser) build_parser_show_build(subparser) -- 2.21.0

The implementation is a bit awkward and surprising because the GET /api/v1/repository/{repository}/tag/{tag}/images API does not report the information we're looking form in an easy to consume form. It also suffers from the same limitations as the list-tags command. Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index 5481e3f..69282da 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -193,6 +193,40 @@ def run_list_tags(config, args): print ("{}".format(tag["name"])) +def run_show_tag(config, args): + endpoint = "/repository/{}/{}/tag".format(args.namespace, args.repo) + params = { + "onlyActiveTags": True, + "limit": 100, + } + + res = get(config, endpoint, params=params, debug=args.debug) + + if has_error(args.quiet, res, 200, + "Cannot list tags for repository {}/{}" + .format(args.namespace, args.repo)): + return 1 + + info = res.json() + + image = None + for tag in info["tags"]: + if tag["name"] == args.tag: + image = tag["image_id"] + break + + if image is not None: + print("tag:") + print(" namespace: {}".format(args.namespace)) + print(" repo: {}".format(args.repo)) + print(" id: {}".format(args.tag)) + print(" image: {}".format(image)) + else: + print("Cannot query tag {} for repository {}/{}: Not Found (404)" + .format(args.tag, args.namespace, args.repo)) + return 1 + + def run_list_builds(config, args): endpoint = "/repository/{}/{}/build".format(args.namespace, args.repo) @@ -314,6 +348,10 @@ def add_arg_build(parser): parser.add_argument("build", help="Build ID") +def add_arg_tag(parser): + parser.add_argument("tag", help="Tag ID") + + def build_parser_list_repos(subparser): parser = subparser.add_parser("list-repos", help="List container repositories") @@ -359,6 +397,16 @@ def build_parser_list_tags(subparser): add_arg_repo(parser) +def build_parser_show_tag(subparser): + parser = subparser.add_parser("show-tag", help="Show tag information") + + parser.set_defaults(func=run_show_tag) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_tag(parser) + + def build_parser_list_builds(subparser): parser = subparser.add_parser("list-builds", help="List repository builds") @@ -425,6 +473,7 @@ def build_parser(): build_parser_delete_repo(subparser) build_parser_list_tags(subparser) + build_parser_show_tag(subparser) build_parser_list_builds(subparser) build_parser_show_build(subparser) -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index 69282da..ad26c75 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -227,6 +227,26 @@ def run_show_tag(config, args): return 1 +def run_create_tag(config, args): + endpoint = "/repository/{}/{}/tag/{}".format(args.namespace, args.repo, + args.tag) + payload = { + "image": args.image, + } + + res = put(config, endpoint, payload=payload, debug=args.debug) + + if has_error(args.quiet, res, 201, + "Cannot create tag {} for repository {}/{}" + .format(args.tag, args.namespace, args.repo)): + return 1 + + if not args.quiet: + print("Tag {} created in repository {}/{}".format(args.tag, + args.namespace, + args.repo)) + + def run_list_builds(config, args): endpoint = "/repository/{}/{}/build".format(args.namespace, args.repo) @@ -352,6 +372,10 @@ def add_arg_tag(parser): parser.add_argument("tag", help="Tag ID") +def add_arg_image(parser): + parser.add_argument("image", help="Image ID") + + def build_parser_list_repos(subparser): parser = subparser.add_parser("list-repos", help="List container repositories") @@ -407,6 +431,17 @@ def build_parser_show_tag(subparser): add_arg_tag(parser) +def build_parser_create_tag(subparser): + parser = subparser.add_parser("create-tag", help="Create a new tag") + + parser.set_defaults(func=run_create_tag) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_tag(parser) + add_arg_image(parser) + + def build_parser_list_builds(subparser): parser = subparser.add_parser("list-builds", help="List repository builds") @@ -474,6 +509,7 @@ def build_parser(): build_parser_list_tags(subparser) build_parser_show_tag(subparser) + build_parser_create_tag(subparser) build_parser_list_builds(subparser) build_parser_show_build(subparser) -- 2.21.0

Signed-off-by: Andrea Bolognani <abologna@redhat.com> --- guests/quayadmin | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/guests/quayadmin b/guests/quayadmin index ad26c75..63af0f9 100755 --- a/guests/quayadmin +++ b/guests/quayadmin @@ -247,6 +247,23 @@ def run_create_tag(config, args): args.repo)) +def run_delete_tag(config, args): + endpoint = "/repository/{}/{}/tag/{}".format(args.namespace, args.repo, + args.tag) + + res = delete(config, endpoint, debug=args.debug) + + if has_error(args.quiet, res, 204, + "Cannot delete tag {} from repository {}/{}" + .format(args.tag, args.namespace, args.repo)): + return 1 + + if not args.quiet: + print("Tag {} deleted from repository {}/{}".format(args.tag, + args.namespace, + args.repo)) + + def run_list_builds(config, args): endpoint = "/repository/{}/{}/build".format(args.namespace, args.repo) @@ -442,6 +459,16 @@ def build_parser_create_tag(subparser): add_arg_image(parser) +def build_parser_delete_tag(subparser): + parser = subparser.add_parser("delete-tag", help="Delete an existing tag") + + parser.set_defaults(func=run_delete_tag) + + add_arg_namespace(parser) + add_arg_repo(parser) + add_arg_tag(parser) + + def build_parser_list_builds(subparser): parser = subparser.add_parser("list-builds", help="List repository builds") @@ -510,6 +537,7 @@ def build_parser(): build_parser_list_tags(subparser) build_parser_show_tag(subparser) build_parser_create_tag(subparser) + build_parser_delete_tag(subparser) build_parser_list_builds(subparser) build_parser_show_build(subparser) -- 2.21.0
participants (3)
-
Andrea Bolognani
-
Daniel P. Berrangé
-
Ján Tomko