mirror of
https://github.com/wassname/ray.git
synced 2026-07-17 11:32:33 +08:00
* Google Cloud Platform scaffolding * Add minimal gcp config example * Add googleapiclient discoveries, update gcp.config constants * Rename and update gcp.config key pair name function * Implement gcp.config._configure_project * Fix the create project get project flow * Implement gcp.config._configure_iam_role * Implement service account iam binding * Implement gcp.config._configure_key_pair * Implement rsa key pair generation * Implement gcp.config._configure_subnet * Save work-in-progress gcp.config._configure_firewall_rules. These are likely to be not needed at all. Saving them if we happen to need them later. * Remove unnecessary firewall configuration * Update example-minimal.yaml configuration * Add new wait_for_compute_operation, rename old wait_for_operation * Temporarily rename autoscaler tags due to gcp incompatibility * Implement initial gcp.node_provider.nodes * Still missing filter support * Implement initial gcp.node_provider.create_node * Implement another compute wait operation (wait_For_compute_zone_operation). TODO: figure out if we can remove the function. * Implement initial gcp.node_provider._node and node status functions * Implement initial gcp.node_provider.terminate_node * Implement node tagging and ip getter methods for nodes * Temporarily rename tags due to gcp incompatibility * Tiny tweaks for autoscaler.updater * Remove unused config from gcp node_provider * Add new example-full example to gcp, update load_gcp_example_config * Implement label filtering for gcp.node_provider.nodes * Revert unnecessary change in ssh command * Revert "Temporarily rename tags due to gcp incompatibility" This reverts commit e2fe634c5d11d705c0f5d3e76c80c37394bb23fb. * Revert "Temporarily rename autoscaler tags due to gcp incompatibility" This reverts commit c938ee435f4b75854a14e78242ad7f1d1ed8ad4b. * Refactor autoscaler tagging to support multiple tag specs * Remove missing cryptography imports * Update quote function import * Fix threading issue in gcp.config with the compute discovery object * Add gcs support for log_sync * Fix the labels/tags naming discrepancy * Add expanduser to file_mounts hashing * Fix gcp.node_provider.internal_ip * Add uuid to node name * Remove 'set -i' from updater ssh command * Also add TODO with the context and reason for the change. * Update ssh key creation in autoscaler.gcp.config * Fix wait_for_compute_zone_operation's threading issue Google discovery api's compute object is not thread safe, and thus needs to be recreated for each thread. This moves the `wait_for_compute_zone_operation` under `autoscaler.gcp.config`, and adds compute as its argument. * Address pr feedback from @ericl * Expand local file mount paths in NodeUpdater * Add ssh_user name to key names * Update updater ssh to attempt 'set -i' and fall back if that fails * Update gcp/example-full.yaml * Fix wait crm operation in gcp.config * Update gcp/example-minimal.yaml to match aws/example-minimal.yaml * Fix gcp/example-full.yaml comment indentation * Add gcp/example-full.yaml to setup files * Update example-full.yaml command * Revert "Refactor autoscaler tagging to support multiple tag specs" This reverts commit 9cf48409ca2e5b66f800153853072c706fa502f6. * Update tag spec to only use characters [0-9a-z_-] * Change the tag values to conform gcp spec * Add project_id in the ssh key name * Replace '_' with '-' in autoscaler tag names * Revert "Update updater ssh to attempt 'set -i' and fall back if that fails" This reverts commit 23a0066c5254449e49746bd5e43b94b66f32bfb4. * Revert "Remove 'set -i' from updater ssh command" This reverts commit 5fa034cdf79fa7f8903691518c0d75699c630172. * Add fallback to `set -i` in force_interactive command * Update autoscaler tests to match current implementation * Update GCPNodeProvider.create_node to include hash in instance name * Add support for creating multiple instance on one create_node call * Clean TODOs * Update styles * Replace single quotes with double quotes * Some minor indentation fixes etc. * Remove unnecessary comment. Fix indentation. * Yapfify files that fail flake8 test * Yapfify more files * Update project_id handling in gcp node provider * temporary yapf mod * Revert "temporary yapf mod" This reverts commit b6744e4e15d4d936d1a14f4bf155ed1d3bb14126. * Fix autoscaler/updater.py lint error, remove unused variable
428 lines
13 KiB
Python
428 lines
13 KiB
Python
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
|
|
import os
|
|
import time
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.hazmat.backends import default_backend
|
|
from googleapiclient import discovery, errors
|
|
|
|
crm = discovery.build("cloudresourcemanager", "v1")
|
|
iam = discovery.build("iam", "v1")
|
|
compute = discovery.build("compute", "v1")
|
|
|
|
VERSION = "v1"
|
|
|
|
RAY = "ray-autoscaler"
|
|
DEFAULT_SERVICE_ACCOUNT_ID = RAY + "-sa-" + VERSION
|
|
SERVICE_ACCOUNT_EMAIL_TEMPLATE = (
|
|
"{account_id}@{project_id}.iam.gserviceaccount.com")
|
|
DEFAULT_SERVICE_ACCOUNT_CONFIG = {
|
|
"displayName": "Ray Autoscaler Service Account ({})".format(VERSION),
|
|
}
|
|
DEFAULT_SERVICE_ACCOUNT_ROLES = ("roles/storage.objectAdmin",
|
|
"roles/compute.admin")
|
|
|
|
MAX_POLLS = 12
|
|
POLL_INTERVAL = 5
|
|
|
|
|
|
def wait_for_crm_operation(operation):
|
|
"""Poll for cloud resource manager operation until finished."""
|
|
print("Waiting for operation {} to finish...".format(operation))
|
|
|
|
for _ in range(MAX_POLLS):
|
|
result = crm.operations().get(name=operation["name"]).execute()
|
|
if "error" in result:
|
|
raise Exception(result["error"])
|
|
|
|
if "done" in result and result["done"]:
|
|
print("Done.")
|
|
break
|
|
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
return result
|
|
|
|
|
|
def wait_for_compute_global_operation(project_name, operation):
|
|
"""Poll for global compute operation until finished."""
|
|
print("Waiting for operation {} to finish...".format(operation["name"]))
|
|
|
|
for _ in range(MAX_POLLS):
|
|
result = compute.globalOperations().get(
|
|
project=project_name,
|
|
operation=operation["name"],
|
|
).execute()
|
|
if "error" in result:
|
|
raise Exception(result["error"])
|
|
|
|
if result["status"] == "DONE":
|
|
print("Done.")
|
|
break
|
|
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
return result
|
|
|
|
|
|
def key_pair_name(i, region, project_id, ssh_user):
|
|
"""Returns the ith default gcp_key_pair_name."""
|
|
key_name = "{}_gcp_{}_{}_{}".format(RAY, region, project_id, ssh_user, i)
|
|
return key_name
|
|
|
|
|
|
def key_pair_paths(key_name):
|
|
"""Returns public and private key paths for a given key_name."""
|
|
public_key_path = os.path.expanduser("~/.ssh/{}.pub".format(key_name))
|
|
private_key_path = os.path.expanduser("~/.ssh/{}.pem".format(key_name))
|
|
return public_key_path, private_key_path
|
|
|
|
|
|
def generate_rsa_key_pair():
|
|
"""Create public and private ssh-keys."""
|
|
|
|
key = rsa.generate_private_key(
|
|
backend=default_backend(), public_exponent=65537, key_size=2048)
|
|
|
|
public_key = key.public_key().public_bytes(
|
|
serialization.Encoding.OpenSSH,
|
|
serialization.PublicFormat.OpenSSH).decode("utf-8")
|
|
|
|
pem = key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
encryption_algorithm=serialization.NoEncryption()).decode("utf-8")
|
|
|
|
return public_key, pem
|
|
|
|
|
|
def bootstrap_gcp(config):
|
|
config = _configure_project(config)
|
|
config = _configure_iam_role(config)
|
|
config = _configure_key_pair(config)
|
|
config = _configure_subnet(config)
|
|
|
|
return config
|
|
|
|
|
|
def _configure_project(config):
|
|
"""Setup a Google Cloud Platform Project.
|
|
|
|
Google Compute Platform organizes all the resources, such as storage
|
|
buckets, users, and instances under projects. This is different from
|
|
aws ec2 where everything is global.
|
|
"""
|
|
project_id = config["provider"].get("project_id")
|
|
assert config["provider"]["project_id"] is not None, (
|
|
"'project_id' must be set in the 'provider' section of the autoscaler"
|
|
" config. Notice that the project id must be globally unique.")
|
|
|
|
project = _get_project(project_id)
|
|
|
|
if project is None:
|
|
# Project not found, try creating it
|
|
_create_project(project_id)
|
|
project = _get_project(project_id)
|
|
|
|
assert project is not None, "Failed to create project"
|
|
assert project["lifecycleState"] == "ACTIVE", (
|
|
"Project status needs to be ACTIVE, got {}".format(
|
|
project["lifecycleState"]))
|
|
|
|
config["provider"]["project_id"] = project["projectId"]
|
|
|
|
return config
|
|
|
|
|
|
def _configure_iam_role(config):
|
|
"""Setup a gcp service account with IAM roles.
|
|
|
|
Creates a gcp service acconut and binds IAM roles which allow it to control
|
|
control storage/compute services. Specifically, the head node needs to have
|
|
an IAM role that allows it to create further gce instances and store items
|
|
in google cloud storage.
|
|
|
|
TODO: Allow the name/id of the service account to be configured
|
|
"""
|
|
email = SERVICE_ACCOUNT_EMAIL_TEMPLATE.format(
|
|
account_id=DEFAULT_SERVICE_ACCOUNT_ID,
|
|
project_id=config["provider"]["project_id"])
|
|
service_account = _get_service_account(email, config)
|
|
|
|
if service_account is None:
|
|
print("Creating new service account {}".format(
|
|
DEFAULT_SERVICE_ACCOUNT_ID))
|
|
|
|
service_account = _create_service_account(
|
|
DEFAULT_SERVICE_ACCOUNT_ID, DEFAULT_SERVICE_ACCOUNT_CONFIG, config)
|
|
|
|
assert service_account is not None, "Failed to create service account"
|
|
|
|
_add_iam_policy_binding(service_account, DEFAULT_SERVICE_ACCOUNT_ROLES)
|
|
|
|
config["head_node"]["serviceAccounts"] = [{
|
|
"email": service_account["email"],
|
|
# NOTE: The amount of access is determined by the scope + IAM
|
|
# role of the service account. Even if the cloud-platform scope
|
|
# gives (scope) access to the whole cloud-platform, the service
|
|
# account is limited by the IAM rights specified below.
|
|
"scopes": ["https://www.googleapis.com/auth/cloud-platform"]
|
|
}]
|
|
|
|
return config
|
|
|
|
|
|
def _configure_key_pair(config):
|
|
"""Configure SSH access, using an existing key pair if possible.
|
|
|
|
Creates a project-wide ssh key that can be used to access all the instances
|
|
unless explicitly prohibited by instance config.
|
|
|
|
The ssh-keys created by ray are of format:
|
|
|
|
[USERNAME]:ssh-rsa [KEY_VALUE] [USERNAME]
|
|
|
|
where:
|
|
|
|
[USERNAME] is the user for the SSH key, specified in the config.
|
|
[KEY_VALUE] is the public SSH key value.
|
|
"""
|
|
|
|
if "ssh_private_key" in config["auth"]:
|
|
return config
|
|
|
|
ssh_user = config["auth"]["ssh_user"]
|
|
|
|
project = compute.projects().get(
|
|
project=config["provider"]["project_id"]).execute()
|
|
|
|
# Key pairs associated with project meta data. The key pairs are general,
|
|
# and not just ssh keys.
|
|
ssh_keys_str = next(
|
|
(item for item in project["commonInstanceMetadata"].get("items", [])
|
|
if item["key"] == "ssh-keys"), {}).get("value", "")
|
|
|
|
ssh_keys = ssh_keys_str.split("\n") if ssh_keys_str else []
|
|
|
|
# Try a few times to get or create a good key pair.
|
|
key_found = False
|
|
for i in range(10):
|
|
key_name = key_pair_name(i, config["provider"]["region"],
|
|
config["provider"]["project_id"], ssh_user)
|
|
public_key_path, private_key_path = key_pair_paths(key_name)
|
|
|
|
for ssh_key in ssh_keys:
|
|
key_parts = ssh_key.split(" ")
|
|
if len(key_parts) != 3:
|
|
continue
|
|
|
|
if key_parts[2] == ssh_user and os.path.exists(private_key_path):
|
|
# Found a key
|
|
key_found = True
|
|
break
|
|
|
|
# Create a key since it doesn't exist locally or in GCP
|
|
if not key_found and not os.path.exists(private_key_path):
|
|
print("Creating new key pair {}".format(key_name))
|
|
public_key, private_key = generate_rsa_key_pair()
|
|
|
|
_create_project_ssh_key_pair(project, public_key, ssh_user)
|
|
|
|
with open(private_key_path, "w") as f:
|
|
f.write(private_key)
|
|
os.chmod(private_key_path, 0o600)
|
|
|
|
with open(public_key_path, "w") as f:
|
|
f.write(public_key)
|
|
|
|
key_found = True
|
|
|
|
break
|
|
|
|
if key_found:
|
|
break
|
|
|
|
assert key_found, "SSH keypair for user {} not found for {}".format(
|
|
ssh_user, private_key_path)
|
|
assert os.path.exists(private_key_path), (
|
|
"Private key file {} not found for user {}"
|
|
"".format(private_key_path, ssh_user))
|
|
|
|
print("Private key not specified in config, using {}"
|
|
"".format(private_key_path))
|
|
|
|
config["auth"]["ssh_private_key"] = private_key_path
|
|
|
|
return config
|
|
|
|
|
|
def _configure_subnet(config):
|
|
"""Pick a reasonable subnet if not specified by the config."""
|
|
|
|
subnets = _list_subnets(config)
|
|
|
|
if not subnets:
|
|
raise NotImplementedError("Should be able to create subnet.")
|
|
|
|
# TODO: make sure that we have usable subnet. Maybe call
|
|
# compute.subnetworks().listUsable? For some reason it didn't
|
|
# work out-of-the-box
|
|
default_subnet = subnets[0]
|
|
|
|
if "networkInterfaces" not in config["head_node"]:
|
|
config["head_node"]["networkInterfaces"] = [{
|
|
"subnetwork": default_subnet["selfLink"],
|
|
"accessConfigs": [{
|
|
"name": "External NAT",
|
|
"type": "ONE_TO_ONE_NAT",
|
|
}],
|
|
}]
|
|
|
|
if "networkInterfaces" not in config["worker_nodes"]:
|
|
config["worker_nodes"]["networkInterfaces"] = [{
|
|
"subnetwork": default_subnet["selfLink"],
|
|
"accessConfigs": [{
|
|
"name": "External NAT",
|
|
"type": "ONE_TO_ONE_NAT",
|
|
}],
|
|
}]
|
|
|
|
return config
|
|
|
|
|
|
def _list_subnets(config):
|
|
response = compute.subnetworks().list(
|
|
project=config["provider"]["project_id"],
|
|
region=config["provider"]["region"]).execute()
|
|
|
|
return response["items"]
|
|
|
|
|
|
def _get_subnet(config, subnet_id):
|
|
subnet = compute.subnetworks().get(
|
|
project=config["provider"]["project_id"],
|
|
region=config["provider"]["region"],
|
|
subnetwork=subnet_id,
|
|
).execute()
|
|
|
|
return subnet
|
|
|
|
|
|
def _get_project(project_id):
|
|
try:
|
|
project = crm.projects().get(projectId=project_id).execute()
|
|
except errors.HttpError as e:
|
|
if e.resp.status != 403:
|
|
raise
|
|
project = None
|
|
|
|
return project
|
|
|
|
|
|
def _create_project(project_id):
|
|
operation = crm.projects().create(body={
|
|
"projectId": project_id,
|
|
"name": project_id
|
|
}).execute()
|
|
|
|
result = wait_for_crm_operation(operation)
|
|
|
|
return result
|
|
|
|
|
|
def _get_service_account(account, config):
|
|
project_id = config["provider"]["project_id"]
|
|
full_name = ("projects/{project_id}/serviceAccounts/{account}"
|
|
"".format(project_id=project_id, account=account))
|
|
try:
|
|
service_account = iam.projects().serviceAccounts().get(
|
|
name=full_name).execute()
|
|
except errors.HttpError as e:
|
|
if e.resp.status != 404:
|
|
raise
|
|
service_account = None
|
|
|
|
return service_account
|
|
|
|
|
|
def _create_service_account(account_id, account_config, config):
|
|
project_id = config["provider"]["project_id"]
|
|
|
|
service_account = iam.projects().serviceAccounts().create(
|
|
name="projects/{project_id}".format(project_id=project_id),
|
|
body={
|
|
"accountId": account_id,
|
|
"serviceAccount": account_config,
|
|
}).execute()
|
|
|
|
return service_account
|
|
|
|
|
|
def _add_iam_policy_binding(service_account, roles):
|
|
"""Add new IAM roles for the service account."""
|
|
project_id = service_account["projectId"]
|
|
email = service_account["email"]
|
|
member_id = "serviceAccount:" + email
|
|
|
|
policy = crm.projects().getIamPolicy(resource=project_id).execute()
|
|
|
|
for role in roles:
|
|
role_exists = False
|
|
for binding in policy["bindings"]:
|
|
if binding["role"] == role:
|
|
if member_id not in binding["members"]:
|
|
binding["members"].append(member_id)
|
|
role_exists = True
|
|
|
|
if not role_exists:
|
|
policy["bindings"].append({
|
|
"members": [member_id],
|
|
"role": role,
|
|
})
|
|
|
|
result = crm.projects().setIamPolicy(
|
|
resource=project_id, body={
|
|
"policy": policy,
|
|
}).execute()
|
|
|
|
return result
|
|
|
|
|
|
def _create_project_ssh_key_pair(project, public_key, ssh_user):
|
|
"""Inserts an ssh-key into project commonInstanceMetadata"""
|
|
|
|
key_parts = public_key.split(" ")
|
|
|
|
# Sanity checks to make sure that the generated key matches expectation
|
|
assert len(key_parts) == 2, key_parts
|
|
assert key_parts[0] == "ssh-rsa", key_parts
|
|
|
|
new_ssh_meta = "{ssh_user}:ssh-rsa {key_value} {ssh_user}".format(
|
|
ssh_user=ssh_user, key_value=key_parts[1])
|
|
|
|
common_instance_metadata = project["commonInstanceMetadata"]
|
|
items = common_instance_metadata.get("items", [])
|
|
|
|
ssh_keys_i = next(
|
|
(i for i, item in enumerate(items) if item["key"] == "ssh-keys"), None)
|
|
|
|
if ssh_keys_i is None:
|
|
items.append({"key": "ssh-keys", "value": new_ssh_meta})
|
|
else:
|
|
ssh_keys = items[ssh_keys_i]
|
|
ssh_keys["value"] += "\n" + new_ssh_meta
|
|
items[ssh_keys_i] = ssh_keys
|
|
|
|
common_instance_metadata["items"] = items
|
|
|
|
operation = compute.projects().setCommonInstanceMetadata(
|
|
project=project["name"], body=common_instance_metadata).execute()
|
|
|
|
response = wait_for_compute_global_operation(project["name"], operation)
|
|
|
|
return response
|