Add gcs-upload role

This role can be used to upload build artifacts to GCS.

Change-Id: Id7b58e168e45503a04a7f3cbfd41e237e2594c5b
diff --git a/roles/gcs-upload/README.rst b/roles/gcs-upload/README.rst
new file mode 100644
index 0000000..7ed6967
--- /dev/null
+++ b/roles/gcs-upload/README.rst
@@ -0,0 +1,31 @@
+Upload files to GCS
+
+    container: "{{ gcs_upload_container }}"
+    credentials_file: "{{ gcs_upload_credentials_file }}"
+    project: "{{ gcs_upload_project }}"
+    root: "{{ gcs_upload_root }}"
+    prefix: "{{ gcs_upload_prefix }}"
+
+**Role Variables**
+
+.. zuul:rolevar:: gcs_upload_container
+
+   The name of the container to upload to.
+
+.. zuul:rolevar:: gcs_upload_credentials_file
+   :default: /authdaemon/token
+
+   The token file to use for authentication.
+
+.. zuul:rolevar:: gcs_upload_project
+
+   The project name to use with the auth token.
+
+.. zuul:rolevar:: gcs_upload_prefix
+   :default: ''
+
+   A path prefix to add before each file.
+
+.. zuul:rolevar:: gcs_upload_root
+
+   The root of the directory to upload.
diff --git a/roles/gcs-upload/defaults/main.yaml b/roles/gcs-upload/defaults/main.yaml
new file mode 100644
index 0000000..9f1004e
--- /dev/null
+++ b/roles/gcs-upload/defaults/main.yaml
@@ -0,0 +1,3 @@
+gcs_upload_credentials_file: /authdaemon/token
+gcs_upload_prefix: ''
+gcs_upload_cache_control: null
diff --git a/roles/gcs-upload/library/gcs_upload.py b/roles/gcs-upload/library/gcs_upload.py
new file mode 100644
index 0000000..a33e56a
--- /dev/null
+++ b/roles/gcs-upload/library/gcs_upload.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+#
+# Copyright 2014 Rackspace Australia
+# Copyright 2018-2019 Red Hat, Inc
+# Copyright 2021 Acme Gating, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+# Make coding more python3-ish
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+"""
+Utility to upload files to google
+Run this from the CLI from the zuul/jobs/roles directory with:
+  python -m gcs-upload.library.gcs_upload
+"""
+
+import argparse
+import datetime
+import json
+import logging
+import os
+import time
+try:
+    import queue as queuelib
+except ImportError:
+    import Queue as queuelib
+import sys
+import threading
+
+from google.cloud import storage
+import google.auth.compute_engine.credentials as gce_cred
+import mimetypes
+
+from ansible.module_utils.basic import AnsibleModule
+
+POST_ATTEMPTS = 3
+MAX_UPLOAD_THREADS = 24
+
+
+def retry_function(func):
+    for attempt in range(1, POST_ATTEMPTS + 1):
+        try:
+            return func()
+        except Exception:
+            if attempt >= POST_ATTEMPTS:
+                raise
+            else:
+                logging.exception("Error on attempt %d" % attempt)
+                time.sleep(attempt * 10)
+
+
+class Credentials(gce_cred.Credentials):
+    def _set_path(self, path):
+        """Call this after initialization"""
+        self._path = path
+        self.refresh(None)
+
+    def refresh(self, request):
+        with open(self._path) as f:
+            data = json.loads(f.read())
+        self.token = data['access_token']
+        self.expiry = (datetime.datetime.utcnow() +
+                       datetime.timedelta(seconds=data['expires_in']))
+
+    def with_scopes(self, *args, **kw):
+        ret = super(Credentials, self).with_scopes(*args, **kw)
+        ret._set_path(self._path)
+        return ret
+
+
+class Uploader():
+    def __init__(self, client, container, prefix, cache_control):
+        self.client = client
+        self.prefix = prefix or ''
+        if self.prefix and not self.prefix.endswith('/'):
+            self.prefix += '/'
+        self.cache_control = cache_control
+        self.bucket = client.bucket(container)
+
+    def upload(self, file_list):
+        """Spin up thread pool to upload to storage"""
+        num_threads = min(len(file_list), MAX_UPLOAD_THREADS)
+        threads = []
+        queue = queuelib.Queue()
+        # add items to queue
+        for f in file_list:
+            queue.put(f)
+
+        for x in range(num_threads):
+            t = threading.Thread(target=self.post_thread, args=(queue,))
+            threads.append(t)
+            t.start()
+
+        for t in threads:
+            t.join()
+
+    def post_thread(self, queue):
+        while True:
+            try:
+                file_detail = queue.get_nowait()
+                logging.debug("%s: processing job %s",
+                              threading.current_thread(),
+                              file_detail)
+                retry_function(lambda: self._post_file(file_detail))
+            except IOError:
+                # Do our best to attempt to upload all the files
+                logging.exception("Error opening file")
+                continue
+            except queuelib.Empty:
+                # No more work to do
+                return
+
+    def _post_file(self, file_detail):
+        full_path, relative_path = file_detail
+        relative_path = self.prefix + relative_path
+        data = open(full_path, 'rb')
+        blob = self.bucket.blob(relative_path)
+        if self.cache_control:
+            blob.cache_control = self.cache_control
+        mime_guess, encoding = mimetypes.guess_type(full_path)
+        mimetype = mime_guess if mime_guess else 'application/octet-stream'
+        blob.upload_from_file(data, content_type=mimetype)
+
+
+def run(container, prefix, root, credentials_file=None, project=None,
+        cache_control=None):
+    if credentials_file:
+        cred = Credentials()
+        cred._set_path(credentials_file)
+        client = storage.Client(credentials=cred, project=project)
+    else:
+        client = storage.Client()
+
+    file_list = []
+    if root.endswith('/'):
+        root = root[:-1]
+    for path, folders, files in os.walk(root):
+        for filename in files:
+            full_path = os.path.join(path, filename)
+            relative_path = full_path[len(root)+1:]
+            file_list.append((full_path, relative_path))
+
+    uploader = Uploader(client, container, prefix, cache_control)
+    uploader.upload(file_list)
+    return file_list
+
+
+def ansible_main():
+    module = AnsibleModule(
+        argument_spec=dict(
+            container=dict(required=True, type='str'),
+            root=dict(required=True, type='str'),
+            prefix=dict(type='str'),
+            credentials_file=dict(type='str'),
+            project=dict(type='str'),
+            cache_control=dict(type='str'),
+        )
+    )
+
+    p = module.params
+    file_list = run(p.get('container'), p.get('prefix'), p.get('root'),
+                    credentials_file=p.get('credentials_file'),
+                    project=p.get('project'),
+                    cache_control=p.get('cache_control'))
+    module.exit_json(changed=True, file_list=file_list)
+
+
+def cli_main():
+    parser = argparse.ArgumentParser(
+        description="Upload files to Google Cloud Storage"
+    )
+    parser.add_argument('--verbose', action='store_true',
+                        help='show debug information')
+    parser.add_argument('--credentials-file',
+                        help='A file with Google Cloud credentials')
+    parser.add_argument('--project',
+                        help='Name of the Google Cloud project (required for '
+                             'credential file)')
+    parser.add_argument('container',
+                        help='Name of the container to use when uploading')
+    parser.add_argument('prefix',
+                        help='The prefix under the container root')
+    parser.add_argument('cache_control',
+                        help='The cache-control header to set')
+    parser.add_argument('root',
+                        help='The root of the directory to upload')
+
+    args = parser.parse_args()
+
+    if args.verbose:
+        logging.basicConfig(level=logging.DEBUG)
+        logging.captureWarnings(True)
+
+    file_list = run(args.container, args.prefix, args.root,
+                    credentials_file=args.credentials_file,
+                    project=args.project,
+                    cache_control=args.cache_control)
+
+
+if __name__ == '__main__':
+    if sys.stdin.isatty():
+        cli_main()
+    else:
+        ansible_main()
diff --git a/roles/gcs-upload/tasks/main.yaml b/roles/gcs-upload/tasks/main.yaml
new file mode 100644
index 0000000..61a3219
--- /dev/null
+++ b/roles/gcs-upload/tasks/main.yaml
@@ -0,0 +1,10 @@
+- name: Upload to GCS
+  delegate_to: localhost
+  gcs_upload:
+    container: "{{ gcs_upload_container }}"
+    credentials_file: "{{ gcs_upload_credentials_file }}"
+    project: "{{ gcs_upload_project }}"
+    root: "{{ gcs_upload_root }}"
+    prefix: "{{ gcs_upload_prefix }}"
+    cache_control: "{{ gcs_upload_cache_control }}"
+  register: gcs_upload_results