Add a gcloud wrapper that can be used by our infra scripts.

Refactor create_images.py and create_instances.py to use it.
diff --git a/buildkite/create_images.py b/buildkite/create_images.py
index 40ca2c8..96f88e3 100755
--- a/buildkite/create_images.py
+++ b/buildkite/create_images.py
@@ -14,6 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from datetime import datetime
 import json
 import os
 import queue
@@ -23,7 +24,9 @@
 import tempfile
 import threading
 import time
-from datetime import datetime
+
+import gcloud
+import gcloud_utils
 
 DEBUG = False
 
@@ -81,6 +84,26 @@
             'https://www.googleapis.com/compute/v1/projects/vm-options/global/licenses/enable-vmx'
         ]
     },
+    'philwo-ubuntu1604': {
+        'source_image_project': 'ubuntu-os-cloud',
+        'source_image_family': 'ubuntu-1604-lts',
+        'target_image_family': 'philwo-ubuntu1604',
+        'scripts': [
+            'shell-utils.sh',
+            'setup-ubuntu.sh',
+            'install-azul-zulu.sh',
+            'install-bazel.sh',
+            'install-buildkite-agent.sh',
+            'install-docker.sh',
+            'install-nodejs.sh',
+            'install-python36.sh',
+            'install-android-sdk.sh',
+            'shutdown.sh'
+        ],
+        'licenses': [
+            'https://www.googleapis.com/compute/v1/projects/vm-options/global/licenses/enable-vmx'
+        ]
+    },
     'buildkite-pipeline-ubuntu1604': {
         'source_image_project': 'ubuntu-os-cloud',
         'source_image_family': 'ubuntu-1604-lts',
@@ -104,71 +127,15 @@
     }
 }
 
-PRINT_LOCK = threading.Lock()
 WORK_QUEUE = queue.Queue()
 
 
-def debug(*args, **kwargs):
-    if DEBUG:
-        with PRINT_LOCK:
-            print(*args, **kwargs)
-
-
 def run(args, **kwargs):
-    debug('Running: %s' % ' '.join(args))
     return subprocess.run(args, **kwargs)
 
 
-def wait_for_vm(vm, status):
-    while True:
-        cmd = ['gcloud', 'compute', 'instances', 'describe', vm]
-        cmd += ['--zone', LOCATION, '--format', 'json']
-        result = run(cmd, check=True, stdout=subprocess.PIPE)
-        current_status = json.loads(result.stdout)['status']
-        if current_status == status:
-            debug("wait_for_vm: VM %s reached status %s" % (vm, status))
-            break
-        else:
-            debug("wait_for_vm: Waiting for VM %s to transition from status %s -> %s" %
-                  (vm, current_status, status))
-        time.sleep(1)
-
-
-def print_pretty_logs(vm, log):
-    with PRINT_LOCK:
-        for line in log.splitlines():
-            # Skip empty lines.
-            if not line:
-                continue
-            if 'ubuntu' in vm:
-                match = re.match(r'.*INFO startup-script: (.*)', line)
-                if match:
-                    print("%s: %s" % (vm, match.group(1)))
-            # elif 'windows' in vm:
-            #     match = re.match(r'.*windows-startup-script-ps1: (.*)', line)
-            #     if match:
-            #         print(match.group(1))
-            else:
-                print("%s: %s" % (vm, line))
-
-
-def tail_serial_console(vm, start=None, until=None):
-    next_start = start if start else '0'
-    while True:
-        cmd = ['gcloud', 'compute', 'instances', 'get-serial-port-output', vm]
-        cmd += ['--zone', LOCATION, '--start', next_start]
-        result = run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
-        if result.returncode != 0:
-            break
-        print_pretty_logs(vm, result.stdout)
-        next_start = re.search(r'--start=(\d*)', result.stderr).group(1)
-        if until and until in result.stdout:
-            break
-    return next_start
-
-
-def merge_setup_scripts(vm, scripts):
-    newline = '\r\n' if 'windows' in vm else None
+def merge_setup_scripts(instance_name, scripts):
+    newline = '\r\n' if 'windows' in instance_name else None
     # Merge all setup scripts into one.
     merged_script_path = tempfile.mkstemp()[1]
     with open(merged_script_path, 'w', newline=newline) as merged_script_file:
@@ -178,44 +145,37 @@
     return merged_script_path
 
 
-def create_vm(vm, params):
-    merged_script_path = merge_setup_scripts(vm, params['scripts'])
+def create_instance(instance_name, params):
+    merged_script_path = merge_setup_scripts(instance_name, params['scripts'])
     try:
-        cmd = ['gcloud', 'compute', 'instances', 'create', vm]
-        cmd += ['--zone', LOCATION]
-        cmd += ['--machine-type', 'n1-standard-8']
-        cmd += ['--network', 'buildkite']
-        if 'windows' in vm:
-            cmd += ['--metadata-from-file', 'windows-startup-script-ps1=' + merged_script_path]
+        if 'windows' in instance_name:
+            startup_script = 'windows-startup-script-ps1=' + merged_script_path
         else:
-            cmd += ['--metadata-from-file', 'startup-script=' + merged_script_path]
-        cmd += ['--min-cpu-platform', 'Intel Skylake']
-        cmd += ['--boot-disk-type', 'pd-ssd']
-        cmd += ['--boot-disk-size', '50GB']
+            startup_script = 'startup-script=' + merged_script_path
+
         if 'source_image' in params:
-            cmd += ['--image', params['source_image']]
+            image = {
+                'image': params['source_image']
+            }
         else:
-            cmd += ['--image-project', params['source_image_project']]
-            cmd += ['--image-family', params['source_image_family']]
-        run(cmd)
+            image = {
+                'image-project': params['source_image_project'],
+                'image-family': params['source_image_family']
+            }
+
+        gcloud.create_instance(instance_name,
+            zone=LOCATION,
+            machine_type='n1-standard-8',
+            network='buildkite',
+            metadata_from_file=startup_script,
+            min_cpu_platform='Intel Skylake',
+            boot_disk_type='pd-ssd',
+            boot_disk_size='50GB',
+            **image)
     finally:
         os.remove(merged_script_path)
 
 
-def delete_vm(vm):
-    run(['gcloud', 'compute', 'instances', 'delete', vm, '--quiet', '--zone', LOCATION])
-
-
-def create_image(vm, params):
-    cmd = ['gcloud', 'compute', 'images', 'create', vm]
-    cmd += ['--family', params['target_image_family']]
-    cmd += ['--source-disk', vm]
-    cmd += ['--source-disk-zone', LOCATION]
-    for license in params.get('licenses', []):
-        cmd += ['--licenses', license]
-    run(cmd)
-
-
 # https://stackoverflow.com/a/25802742
 def write_to_clipboard(output):
     process = subprocess.Popen(
@@ -223,25 +183,23 @@
     process.communicate(output.encode('utf-8'))
 
 
-def print_windows_instructions(vm):
-    tail_start = tail_serial_console(vm, until='Finished running startup scripts')
+def print_windows_instructions(instance_name):
+    tail_start = gcloud_utils.tail_serial_console(instance_name, zone=LOCATION, until='Finished running startup scripts')
 
-    cmd = ['gcloud', 'compute', 'reset-windows-password', vm]
-    cmd += ['--format', 'json', '--quiet', '--zone', LOCATION]
-
-    pw = json.loads(run(cmd, check=True, stdout=subprocess.PIPE).stdout)
+    pw = json.loads(gcloud.reset_windows_password(instance_name, format='json', zone=LOCATION).stdout)
     rdp_file = tempfile.mkstemp(suffix='.rdp')[1]
     with open(rdp_file, 'w') as f:
         f.write('full address:s:' + pw['ip_address'] + '\n')
         f.write('username:s:' + pw['username'] + '\n')
-    run(['open', rdp_file])
+    subprocess.run(['open', rdp_file])
     write_to_clipboard(pw['password'])
-    with PRINT_LOCK:
+    with gcloud.PRINT_LOCK:
         print('Use this password to connect to the Windows VM: ' + pw['password'])
         print('Please run the setup script C:\\setup.ps1 once you\'re logged in.')
 
     # Wait until the VM reboots once, then open RDP again.
-    tail_start = tail_serial_console(vm, start=tail_start, until='Finished running startup scripts')
+    tail_start = gcloud_utils.tail_serial_console(
+        instance_name, zone=LOCATION, start=tail_start, until='Finished running startup scripts')
     print('Connecting via RDP a second time to finish the setup...')
     write_to_clipboard(pw['password'])
     run(['open', rdp_file])
@@ -249,30 +207,34 @@
 
 
 def workflow(name, params):
-    vm = "%s-image-%s" % (name, int(datetime.now().timestamp()))
+    instance_name = "%s-image-%s" % (name, int(datetime.now().timestamp()))
     try:
         # Create the VM.
-        create_vm(vm, params)
+        create_instance(instance_name, params)
 
         # Wait for the VM to become ready.
-        wait_for_vm(vm, 'RUNNING')
+        gcloud_utils.wait_for_instance(instance_name, zone=LOCATION, status='RUNNING')
 
-        if 'windows' in vm:
+        if 'windows' in instance_name:
             # Wait for VM to be ready, then print setup instructions.
-            tail_start = print_windows_instructions(vm)
+            tail_start = print_windows_instructions(instance_name)
             # Continue printing the serial console until the VM shuts down.
-            tail_serial_console(vm, start=tail_start)
+            gcloud_utils.tail_serial_console(instance_name, zone=LOCATION, start=tail_start)
         else:
             # Continuously print the serial console.
-            tail_serial_console(vm)
+            gcloud_utils.tail_serial_console(instance_name, zone=LOCATION)
 
         # Wait for the VM to completely shutdown.
-        wait_for_vm(vm, 'TERMINATED')
+        gcloud_utils.wait_for_instance(instance_name, zone=LOCATION, status='TERMINATED')
 
         # Create a new image from our VM.
-        create_image(vm, params)
+        gcloud.create_image(instance_name,
+            family=params['target_image_family'],
+            source_disk=instance_name,
+            source_disk_zone=LOCATION,
+            licenses=params.get('licenses', []))
     finally:
-        delete_vm(vm)
+        gcloud.delete_instance(instance_name)
 
 
 def worker():
diff --git a/buildkite/create_instances.py b/buildkite/create_instances.py
index cfaf184..3caf5a3 100755
--- a/buildkite/create_instances.py
+++ b/buildkite/create_instances.py
@@ -21,6 +21,9 @@
 import sys
 import threading
 
+import gcloud
+
+
 DEBUG = True
 
 LOCATION = 'europe-west1-d'
@@ -35,68 +38,63 @@
 # - "$USER": This is a VM used by one specific engineer for tests. It does not run the Buildkite
 #            agent.
 #
+DEFAULT_VM = {
+    'boot_disk_size': '50GB',
+    'boot_disk_type': 'pd-ssd',
+    'image_project': 'bazel-public',
+    'machine_type': 'n1-standard-32',
+    'min_cpu_platform': 'Intel Skylake',
+    'network': 'buildkite',
+    'scopes': 'cloud-platform',
+    'service_account': 'remote-account@bazel-public.iam.gserviceaccount.com',
+}
+
 INSTANCE_GROUPS = {
     'buildkite-ubuntu1404': {
         'count': 8,
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
+        'image_family': 'buildkite-ubuntu1404',
         'local_ssd': 'interface=nvme',
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
     },
     'buildkite-ubuntu1604': {
         'count': 8,
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
+        'image_family': 'buildkite-ubuntu1604',
         'local_ssd': 'interface=nvme',
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
     },
     'buildkite-windows': {
         'count': 4,
-        'startup_script': 'startup-windows.ps1',
-        'machine_type': 'n1-standard-32',
+        'image_family': 'buildkite-windows',
         'local_ssd': 'interface=scsi',
+        'metadata_from_file': 'windows-startup-script-ps1=startup-windows.ps1',
     },
 }
 
 SINGLE_INSTANCES = {
     'buildkite-pipeline-ubuntu1604': {
-        'startup_script': 'startup-ubuntu.sh',
+        'image_family': 'buildkite-pipeline-ubuntu1604',
         'machine_type': 'n1-standard-8',
-        'persistent_disk': 'buildkite-pipeline-persistent'
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
+        'persistent_disk': 'name={0},device-name={0},mode=rw,boot=no'.format('buildkite-pipeline-persistent'),
     },
     'testing-ubuntu1404': {
         'image_family': 'buildkite-ubuntu1404',
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
-        'persistent_disk': 'testing-ubuntu1404-persistent'
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
+        'persistent_disk': 'name={0},device-name={0},mode=rw,boot=no'.format('testing-ubuntu1404-persistent'),
     },
     'testing-ubuntu1604': {
         'image_family': 'buildkite-ubuntu1604',
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
-        'persistent_disk': 'testing-ubuntu1604-persistent'
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
+        'persistent_disk': 'name={0},device-name={0},mode=rw,boot=no'.format('testing-ubuntu1604-persistent'),
+    },
+    'philwo-ubuntu1604': {
+        'image_family': 'buildkite-ubuntu1604',
+        'metadata_from_file': 'startup-script=startup-ubuntu.sh',
     },
     'testing-windows': {
+        'boot_disk_size': '500GB',
         'image_family': 'buildkite-windows',
-        'machine_type': 'n1-standard-32',
-        'boot_disk_size': '500GB'
     },
-    '{}-ubuntu1404'.format(getpass.getuser()): {
-        'image_family': 'buildkite-ubuntu1404',
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
-        'local_ssd': 'interface=nvme',
-    },
-    '{}-ubuntu1604'.format(getpass.getuser()): {
-        'image_family': 'buildkite-ubuntu1604',
-        'startup_script': 'startup-ubuntu.sh',
-        'machine_type': 'n1-standard-32',
-        'local_ssd': 'interface=nvme',
-    },
-    '{}-windows'.format(getpass.getuser()): {
-        'image_family': 'buildkite-windows',
-        'startup_script': 'startup-windows.ps1',
-        'machine_type': 'n1-standard-32',
-        'local_ssd': 'interface=scsi',
-    }
 }
 
 PRINT_LOCK = threading.Lock()
@@ -113,105 +111,27 @@
     return subprocess.run(args, **kwargs)
 
 
-def flags_for_instance(image_family, params):
-    cmd = ['--machine-type', params['machine_type']]
-    cmd.extend(['--network', 'buildkite'])
-    if 'startup_script' in params:
-        if 'windows' in image_family:
-            cmd.extend(['--metadata-from-file',
-                        'windows-startup-script-ps1=' + params['startup_script']])
-        else:
-            cmd.extend(['--metadata-from-file', 'startup-script=' + params['startup_script']])
-    cmd.extend(['--min-cpu-platform', 'Intel Skylake'])
-    cmd.extend(['--boot-disk-type', 'pd-ssd'])
-    cmd.extend(['--boot-disk-size', params.get('boot_disk_size', '50GB')])
-    if 'local_ssd' in params:
-        cmd.extend(['--local-ssd', params['local_ssd']])
-    if 'persistent_disk' in params:
-        cmd.extend(['--disk',
-                    'name={0},device-name={0},mode=rw,boot=no'.format(params['persistent_disk'])])
-    cmd.extend(['--image-project', 'bazel-public'])
-    cmd.extend(['--image-family', image_family])
-    cmd.extend(['--service-account', 'remote-account@bazel-public.iam.gserviceaccount.com'])
-    cmd.extend(['--scopes', 'cloud-platform'])
-    return cmd
-
-
-def delete_instance_template(template_name):
-    cmd = ['gcloud', 'compute', 'instance-templates', 'delete', template_name, '--quiet']
-    result = run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
-    if result.returncode != 0:
-        # It's not an error if 'delete' failed, because the template didn't exist in the first place.
-        # But we do want to error out on other unexpected errors.
-        if not re.search(r'The resource .* was not found', result.stdout):
-            raise Exception('"gcloud compute instance-templates delete" returned unexpected error:\n{}'.format(result.stdout))
-    return result
-
-
-def create_instance_template(template_name, image_family, params):
-    cmd = ['gcloud', 'compute', 'instance-templates', 'create', template_name]
-    cmd.extend(flags_for_instance(image_family, params))
-    run(cmd)
-
-
-def delete_instance(instance_name):
-    cmd = ['gcloud', 'compute', 'instances', 'delete', '--quiet', instance_name]
-    result = run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
-    if result.returncode != 0:
-        # It's not an error if 'delete' failed, because the template didn't exist in the first place.
-        # But we do want to error out on other unexpected errors.
-        if not re.search(r'The resource .* was not found', result.stdout):
-            raise Exception('"gcloud compute instance delete" returned unexpected error:\n{}'.format(result.stdout))
-    return result
-
-
-def create_instance(instance_name, image_family, params):
-    cmd = ['gcloud', 'compute', 'instances', 'create', instance_name]
-    cmd.extend(['--zone', LOCATION])
-    cmd.extend(flags_for_instance(image_family, params))
-    run(cmd)
-
-
-def delete_instance_group(instance_group_name):
-    cmd = ['gcloud', 'compute', 'instance-groups', 'managed', 'delete', instance_group_name]
-    cmd.extend(['--zone', LOCATION])
-    cmd.extend(['--quiet'])
-    result = run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
-    if result.returncode != 0:
-        # It's not an error if 'delete' failed, because the template didn't exist in the first place.
-        # But we do want to error out on other unexpected errors.
-        if not re.search(r'The resource .* was not found', result.stdout):
-            raise Exception('"gcloud compute instance-groups managed delete" returned unexpected error:\n{}'.format(result.stdout))
-    return result
-
-
-def create_instance_group(instance_group_name, template_name, count):
-    cmd = ['gcloud', 'compute', 'instance-groups', 'managed', 'create', instance_group_name]
-    cmd.extend(['--zone', LOCATION])
-    cmd.extend(['--base-instance-name', instance_group_name])
-    cmd.extend(['--template', template_name])
-    cmd.extend(['--size', str(count)])
-    return run(cmd)
-
-
-def instance_group_task(instance_group_name, params):
-    image_family = params.get('image_family', instance_group_name)
+def instance_group_task(instance_group_name, count, **kwargs):
     template_name = instance_group_name + '-template'
 
-    if delete_instance_group(instance_group_name).returncode == 0:
+    if gcloud.delete_instance_group(instance_group_name, zone=LOCATION).returncode == 0:
         print('Deleted existing instance group: {}'.format(instance_group_name))
-    if delete_instance_template(template_name).returncode == 0:
+
+    if gcloud.delete_instance_template(template_name, zone=LOCATION).returncode == 0:
         print('Deleted existing VM template: {}'.format(template_name))
-    create_instance_template(template_name, image_family, params)
-    create_instance_group(instance_group_name, template_name, params['count'])
+
+    gcloud.create_instance_template(template_name, **kwargs)
+
+    gcloud.create_instance_group(instance_group_name,
+        zone=LOCATION, base_instance_name=instance_group_name, template=template_name,
+        size=count)
 
 
-def single_instance_task(instance_name, params):
-    image_family = params.get('image_family', instance_name)
-
-    if delete_instance(instance_name).returncode == 0:
+def single_instance_task(instance_name, **kwargs):
+    if gcloud.delete_instance(instance_name, zone=LOCATION).returncode == 0:
         print('Deleted existing instance: {}'.format(instance_name))
-    create_instance(instance_name, image_family, params)
+
+    gcloud.create_instance(instance_name, zone=LOCATION, **kwargs)
 
 
 def worker():
@@ -240,13 +160,10 @@
         # instances, otherwise we process all.
         if argv and instance_group_name not in argv:
             continue
-        # Do not automatically create user-specific instances. These must be specified explicitly
-        # on the command-line.
-        if instance_group_name.startswith(getpass.getuser()) and instance_group_name not in argv:
-            continue
         WORK_QUEUE.put({
+            **DEFAULT_VM,
             'instance_group_name': instance_group_name,
-            'params': params
+            **params
         })
 
     for instance_name, params in SINGLE_INSTANCES.items():
@@ -254,13 +171,10 @@
         # instances, otherwise we process all.
         if argv and instance_name not in argv:
             continue
-        # Do not automatically create user-specific instances. These must be specified explicitly
-        # on the command-line.
-        if instance_name.startswith(getpass.getuser()) and instance_name not in argv:
-            continue
         WORK_QUEUE.put({
+            **DEFAULT_VM,
             'instance_name': instance_name,
-            'params': params
+            **params
         })
 
     # Spawn worker threads that will create the VMs.
diff --git a/buildkite/gcloud.py b/buildkite/gcloud.py
new file mode 100644
index 0000000..40cf4d1
--- /dev/null
+++ b/buildkite/gcloud.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+#
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+import collections
+import re
+import subprocess
+import threading
+
+DEBUG = True
+PRINT_LOCK = threading.Lock()
+
+
+def debug(*args, **kwargs):
+    if DEBUG:
+        with PRINT_LOCK:
+            print(*args, **kwargs)
+
+
+def is_sequence(seq):
+  return isinstance(seq, collections.Sequence) and not isinstance(seq, str)
+
+
+def gcloud(*args, **kwargs):
+    cmd = ['gcloud']
+    cmd += args
+    for flag, value in kwargs.items():
+        # Python uses underscores as word delimiters in kwargs, but gcloud wants dashes.
+        flag = flag.replace('_', '-')
+        # We convert key=[a, b] into two flags: --key=a --key=b.
+        if is_sequence(value):
+            for item in value:
+                cmd += ['--' + flag, str(item)]
+        else:
+            cmd += ['--' + flag, str(value)]
+    # Throws `subprocess.CalledProcessError` if the process exits with return code > 0.
+    return subprocess.run(
+        cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True)
+
+
+def create_instance(name, **kwargs):
+    try:
+        return gcloud('compute', 'instances', 'create', name, **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute instance create" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def delete_instance(name, **kwargs):
+    try:
+        return gcloud('compute', 'instances', 'delete', name, '--quiet', **kwargs)
+    except subprocess.CalledProcessError as e:
+        # It's not an error if 'delete' failed, because the object didn't exist in the first place.
+        # But we do want to error out on other unexpected errors.
+        if not re.search(r'The resource .* was not found', e.stderr):
+            raise Exception('"gcloud compute instance delete" returned unexpected error:\n{}'.format(e.stderr))
+        return e
+
+
+def describe_instance(name, **kwargs):
+    try:
+        return gcloud('compute', 'instances', 'describe', name, **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute instance describe" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def create_instance_group(name, **kwargs):
+    try:
+        return gcloud('compute', 'instance-groups', 'managed', 'create', name, **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute instance-groups managed create" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def delete_instance_group(name, **kwargs):
+    try:
+        return gcloud('compute', 'instance-groups', 'managed', 'delete', name, '--quiet', **kwargs)
+    except subprocess.CalledProcessError as e:
+        # It's not an error if 'delete' failed, because the object didn't exist in the first place.
+        # But we do want to error out on other unexpected errors.
+        if not re.search(r'The resource .* was not found', e.stderr):
+            raise Exception('"gcloud compute instance-groups managed delete" returned unexpected error:\n{}'.format(e.stderr))
+        return e
+
+
+def create_instance_template(name, **kwargs):
+    try:
+        return gcloud('compute', 'instance-templates', 'create', name, **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute instance-templates create" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def delete_instance_template(name, **kwargs):
+    try:
+        return gcloud('compute', 'instance-templates', 'delete', name, '--quiet', **kwargs)
+    except subprocess.CalledProcessError as e:
+        # It's not an error if 'delete' failed, because the object didn't exist in the first place.
+        # But we do want to error out on other unexpected errors.
+        if not re.search(r'The resource .* was not found', e.stderr):
+            raise Exception('"gcloud compute instance-templates delete" returned unexpected error:\n{}'.format(e.stderr))
+        return e
+
+
+def create_image(name, **kwargs):
+    try:
+        return gcloud('compute', 'images', 'create', name, **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute images create" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def reset_windows_password(name, **kwargs):
+    try:
+        return gcloud('compute', 'reset-windows-password', name, '--quiet', **kwargs)
+    except subprocess.CalledProcessError as e:
+        raise Exception('"gcloud compute reset-windows-password" returned unexpected error:\n{}'.format(e.stderr))
+
+
+def get_serial_port_output(name, **kwargs):
+    return gcloud('compute', 'instances', 'get-serial-port-output', name, **kwargs)
diff --git a/buildkite/gcloud_utils.py b/buildkite/gcloud_utils.py
new file mode 100644
index 0000000..3d7f41d
--- /dev/null
+++ b/buildkite/gcloud_utils.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+#
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+import gcloud
+import json
+import subprocess
+import time
+import threading
+import re
+
+
+def wait_for_instance(instance_name, zone, status):
+    while True:
+        result = gcloud.describe_instance(instance_name, zone=zone, format='json')
+        current_status = json.loads(result.stdout)['status']
+        if current_status == status:
+            gcloud.debug('wait_for_instance: {}/{} arrived at status {}'.format(zone, instance_name, status))
+            break
+        else:
+            gcloud.debug('wait_for_instance: Waiting for {}/{} to go from status {} to status {}'.format(
+                zone, instance_name, current_status, status))
+        time.sleep(5)
+
+
+def prettify_logs(instance_name, log, with_prefix=True):
+    for line in log.splitlines():
+        # Skip empty lines.
+        if not line:
+            continue
+
+        # Filter for log lines printed by our startup script, ignore the rest.
+        # Then drop the common prefix to make the output easier to read.
+        # For unknown platforms, we just take every line unmodified.
+        if 'ubuntu' in instance_name:
+            match = re.match(r'.*INFO startup-script: (.*)', line)
+            if not match:
+                continue
+            line = match.group(1)
+        elif 'windows' in instance_name:
+            match = re.match(r'.*windows-startup-script-ps1: (.*)', line)
+            if not match:
+                continue
+            line = match.group(1)
+
+        if with_prefix:
+            yield "{}: {}".format(instance_name, line)
+        else:
+            yield line
+
+
+def print_pretty_logs(instance_name, log):
+    lines = ('\n'.join(prettify_logs(instance_name, log))).strip()
+    if lines:
+        with gcloud.PRINT_LOCK:
+            print(lines)
+
+
+def tail_serial_console(instance_name, zone, start=None, until=None):
+    next_start = start if start else '0'
+    while True:
+        try:
+            result = gcloud.get_serial_port_output(instance_name, zone=zone, start=next_start)
+        except subprocess.CalledProcessError as e:
+            gcloud.debug('tail_serial_console: Done, because got exception: {}'.format(e))
+            if e.stdout:
+                gcloud.debug('stdout: ' + e.stdout)
+            if e.stderr:
+                gcloud.debug('stderr: ' + e.stderr)
+            break
+        print_pretty_logs(instance_name, result.stdout)
+        next_start = re.search(r'--start=(\d*)', result.stderr).group(1)
+        if until and until in result.stdout:
+            gcloud.debug('tail_serial_console: Done, because found string "{}"'.format(until))
+            break
+        time.sleep(5)
+    return next_start