initial public release

This commit is contained in:
Brian Caswell
2020-09-18 12:21:04 -04:00
parent 9c3aa0bdfb
commit d3a0b292e6
387 changed files with 43810 additions and 28 deletions

655
src/deployment/deploy.py Normal file
View File

@ -0,0 +1,655 @@
#!/usr/bin/env python
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
import argparse
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import uuid
import zipfile
from datetime import datetime, timedelta
from azure.common.client_factory import get_client_from_cli_profile
from azure.common.credentials import get_cli_profile
from azure.core.exceptions import ResourceExistsError
from azure.graphrbac import GraphRbacManagementClient
from azure.graphrbac.models import (
ApplicationCreateParameters,
AppRole,
GraphErrorException,
OptionalClaims,
RequiredResourceAccess,
ResourceAccess,
ServicePrincipalCreateParameters,
)
from azure.mgmt.eventgrid import EventGridManagementClient
from azure.mgmt.eventgrid.models import (
EventSubscription,
EventSubscriptionFilter,
RetryPolicy,
StorageQueueEventSubscriptionDestination,
)
from azure.mgmt.resource import ResourceManagementClient, SubscriptionClient
from azure.mgmt.resource.resources.models import (
Deployment,
DeploymentMode,
DeploymentProperties,
)
from azure.mgmt.storage import StorageManagementClient
from azure.storage.blob import (
BlobServiceClient,
ContainerSasPermissions,
generate_container_sas,
)
from azure.storage.queue import QueueServiceClient
from msrest.serialization import TZ_UTC
from urllib3.util.retry import Retry
from register_pool_application import (
add_application_password,
authorize_application,
update_registration,
get_application,
register_application,
)
USER_IMPERSONATION = "311a71cc-e848-46a1-bdf8-97ff7156d8e6"
ONEFUZZ_CLI_APP = "72f1562a-8c0c-41ea-beb9-fa2b71c80134"
ONEFUZZ_CLI_AUTHORITY = (
"https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47"
)
TELEMETRY_NOTICE = (
"Telemetry collection on stats and OneFuzz failures are sent to Microsoft. "
"To disable, delete the ONEFUZZ_TELEMETRY application setting in the "
"Azure Functions instance"
)
FUNC_TOOLS_ERROR = (
"azure-functions-core-tools is not installed, "
"install v3 using instructions: "
"https://github.com/Azure/azure-functions-core-tools#installing"
)
logger = logging.getLogger("deploy")
def gen_guid():
return str(uuid.uuid4())
class Client:
def __init__(
self,
resource_group,
location,
application_name,
owner,
client_id,
client_secret,
app_zip,
tools,
instance_specific,
third_party,
arm_template,
workbook_data,
create_registration,
):
self.resource_group = resource_group
self.arm_template = arm_template
self.location = location
self.application_name = application_name
self.owner = owner
self.app_zip = app_zip
self.tools = tools
self.instance_specific = instance_specific
self.third_party = third_party
self.create_registration = create_registration
self.results = {
"client_id": client_id,
"client_secret": client_secret,
}
self.cli_config = {
"client_id": ONEFUZZ_CLI_APP,
"authority": ONEFUZZ_CLI_AUTHORITY,
}
if os.name == "nt":
self.azcopy = os.path.join(self.tools, "win64", "azcopy.exe")
else:
self.azcopy = os.path.join(self.tools, "linux", "azcopy")
subprocess.check_output(["chmod", "+x", self.azcopy])
with open(workbook_data) as f:
self.workbook_data = json.load(f)
def get_subscription_id(self):
profile = get_cli_profile()
return profile.get_subscription_id()
def get_location_display_name(self):
location_client = get_client_from_cli_profile(SubscriptionClient)
locations = location_client.subscriptions.list_locations(
self.get_subscription_id()
)
for location in locations:
if location.name == self.location:
return location.display_name
raise Exception("unknown location: %s", self.location)
def check_region(self):
# At the moment, this only checks are the specified providers available
# in the selected region
location = self.get_location_display_name()
with open(self.arm_template, "r") as handle:
arm = json.load(handle)
client = get_client_from_cli_profile(ResourceManagementClient)
providers = {x.namespace: x for x in client.providers.list()}
unsupported = []
for resource in arm["resources"]:
namespace, name = resource["type"].split("/", 1)
# resource types are in the form of a/b/c....
# only the top two are listed as resource types within providers
name = "/".join(name.split("/")[:2])
if namespace not in providers:
unsupported.append("Unsupported provider: %s" % namespace)
continue
provider = providers[namespace]
resource_types = {x.resource_type: x for x in provider.resource_types}
if name not in resource_types:
unsupported.append(
"Unsupported resource type: %s/%s" % (namespace, name)
)
continue
resource_type = resource_types[name]
if (
location not in resource_type.locations
and len(resource_type.locations) > 0
):
unsupported.append(
"%s/%s is unsupported in %s" % (namespace, name, self.location)
)
if unsupported:
print("The following resources required by onefuzz are not supported:")
print("\n".join(["* " + x for x in unsupported]))
sys.exit(1)
def setup_rbac(self):
"""
Setup the client application for the OneFuzz instance.
By default, Service Principals do not have access to create
client applications in AAD.
"""
if self.results["client_id"] and self.results["client_secret"]:
logger.info("using existing client application")
return
client = get_client_from_cli_profile(GraphRbacManagementClient)
logger.info("checking if RBAC already exists")
try:
existing = list(
client.applications.list(
filter="displayName eq '%s'" % self.application_name
)
)
except GraphErrorException:
logger.error("unable to query RBAC. Provide client_id and client_secret")
sys.exit(1)
if not existing:
logger.info("creating Application registration")
url = "https://%s.azurewebsites.net" % self.application_name
params = ApplicationCreateParameters(
display_name=self.application_name,
identifier_uris=[url],
reply_urls=[url + "/.auth/login/aad/callback"],
optional_claims=OptionalClaims(id_token=[], access_token=[]),
required_resource_access=[
RequiredResourceAccess(
resource_access=[
ResourceAccess(id=USER_IMPERSONATION, type="Scope")
],
resource_app_id="00000002-0000-0000-c000-000000000000",
)
],
app_roles=[
AppRole(
allowed_member_types=["Application"],
display_name="CliClient",
id=str(uuid.uuid4()),
is_enabled=True,
description="Allows access from the CLI.",
value="CliClient",
),
AppRole(
allowed_member_types=["Application"],
display_name="LabMachine",
id=str(uuid.uuid4()),
is_enabled=True,
description="Allow access from a lab machine.",
value="LabMachine",
),
],
)
app = client.applications.create(params)
logger.info("creating service principal")
service_principal_params = ServicePrincipalCreateParameters(
account_enabled=True,
app_role_assignment_required=False,
service_principal_type="Application",
app_id=app.app_id,
)
client.service_principals.create(service_principal_params)
else:
app = existing[0]
creds = list(client.applications.list_password_credentials(app.object_id))
client.applications.update_password_credentials(app.object_id, creds)
(password_id, password) = add_application_password(app.object_id)
onefuzz_cli_app_uuid = uuid.UUID(ONEFUZZ_CLI_APP)
cli_app = get_application(onefuzz_cli_app_uuid)
if cli_app is None:
logger.info(
"Could not find the default CLI application under the current subscription, creating a new one"
)
app_info = register_application("onefuzz-cli", self.application_name)
self.cli_config = {
"client_id": app_info.client_id,
"authority": app_info.authority,
}
else:
authorize_application(onefuzz_cli_app_uuid, app.app_id)
self.results["client_id"] = app.app_id
self.results["client_secret"] = password
# Log `client_secret` for consumption by CI.
logger.debug("client_id: %s client_secret: %s", app.app_id, password)
def deploy_template(self):
logger.info("deploying arm template: %s", self.arm_template)
with open(self.arm_template, "r") as template_handle:
template = json.load(template_handle)
client = get_client_from_cli_profile(ResourceManagementClient)
client.resource_groups.create_or_update(
self.resource_group, {"location": self.location}
)
expiry = (datetime.now(TZ_UTC) + timedelta(days=365)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
params = {
"name": {"value": self.application_name},
"owner": {"value": self.owner},
"clientId": {"value": self.results["client_id"]},
"clientSecret": {"value": self.results["client_secret"]},
"signedExpiry": {"value": expiry},
"workbookData": {"value": self.workbook_data},
}
deployment = Deployment(
properties=DeploymentProperties(
mode=DeploymentMode.incremental, template=template, parameters=params
)
)
result = client.deployments.create_or_update(
self.resource_group, gen_guid(), deployment
).result()
if result.properties.provisioning_state != "Succeeded":
logger.error(
"error deploying: %s",
json.dumps(result.as_dict(), indent=4, sort_keys=True),
)
sys.exit(1)
self.results["deploy"] = result.properties.outputs
def create_queues(self):
logger.info("creating eventgrid destination queue")
name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"]
account_url = "https://%s.queue.core.windows.net" % name
client = QueueServiceClient(
account_url=account_url,
credential={"account_name": name, "account_key": key},
)
for queue in ["file-changes", "heartbeat", "proxy", "update-queue"]:
try:
client.create_queue(queue)
except ResourceExistsError:
pass
def create_eventgrid(self):
logger.info("creating eventgrid subscription")
src_resource_id = self.results["deploy"]["fuzz-storage"]["value"]
dst_resource_id = self.results["deploy"]["func-storage"]["value"]
client = get_client_from_cli_profile(StorageManagementClient)
event_subscription_info = EventSubscription(
destination=StorageQueueEventSubscriptionDestination(
resource_id=dst_resource_id, queue_name="file-changes"
),
filter=EventSubscriptionFilter(
included_event_types=[
"Microsoft.Storage.BlobCreated",
"Microsoft.Storage.BlobDeleted",
]
),
retry_policy=RetryPolicy(
max_delivery_attempts=30,
event_time_to_live_in_minutes=1440,
),
)
client = get_client_from_cli_profile(EventGridManagementClient)
result = client.event_subscriptions.create_or_update(
src_resource_id, "onefuzz1", event_subscription_info
).result()
if result.provisioning_state != "Succeeded":
raise Exception(
"eventgrid subscription failed: %s"
% json.dumps(result.as_dict(), indent=4, sort_keys=True),
)
def upload_tools(self):
logger.info("uploading tools from %s", self.tools)
account_name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"]
account_url = "https://%s.blob.core.windows.net" % account_name
client = BlobServiceClient(account_url, credential=key)
if "tools" not in [x["name"] for x in client.list_containers()]:
client.create_container("tools")
expiry = datetime.utcnow() + timedelta(minutes=30)
sas = generate_container_sas(
account_name,
"tools",
account_key=key,
permission=ContainerSasPermissions(
read=True, write=True, delete=True, list=True
),
expiry=expiry,
)
url = "%s/%s?%s" % (account_url, "tools", sas)
subprocess.check_output(
[self.azcopy, "sync", self.tools, url, "--delete-destination", "true"]
)
def upload_instance_setup(self):
logger.info("uploading instance-specific-setup from %s", self.instance_specific)
account_name = self.results["deploy"]["func-name"]["value"]
key = self.results["deploy"]["func-key"]["value"]
account_url = "https://%s.blob.core.windows.net" % account_name
client = BlobServiceClient(account_url, credential=key)
if "instance-specific-setup" not in [
x["name"] for x in client.list_containers()
]:
client.create_container("instance-specific-setup")
expiry = datetime.utcnow() + timedelta(minutes=30)
sas = generate_container_sas(
account_name,
"instance-specific-setup",
account_key=key,
permission=ContainerSasPermissions(
read=True, write=True, delete=True, list=True
),
expiry=expiry,
)
url = "%s/%s?%s" % (account_url, "instance-specific-setup", sas)
subprocess.check_output(
[
self.azcopy,
"sync",
self.instance_specific,
url,
"--delete-destination",
"true",
]
)
def upload_third_party(self):
logger.info("uploading third-party tools from %s", self.third_party)
account_name = self.results["deploy"]["fuzz-name"]["value"]
key = self.results["deploy"]["fuzz-key"]["value"]
account_url = "https://%s.blob.core.windows.net" % account_name
client = BlobServiceClient(account_url, credential=key)
containers = [x["name"] for x in client.list_containers()]
for name in os.listdir(self.third_party):
path = os.path.join(self.third_party, name)
if not os.path.isdir(path):
continue
if name not in containers:
client.create_container(name)
expiry = datetime.utcnow() + timedelta(minutes=30)
sas = generate_container_sas(
account_name,
name,
account_key=key,
permission=ContainerSasPermissions(
read=True, write=True, delete=True, list=True
),
expiry=expiry,
)
url = "%s/%s?%s" % (account_url, name, sas)
subprocess.check_output(
[self.azcopy, "sync", path, url, "--delete-destination", "true"]
)
def deploy_app(self):
logger.info("deploying function app %s", self.app_zip)
current_dir = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdirname:
with zipfile.ZipFile(self.app_zip, "r") as zip_ref:
zip_ref.extractall(tmpdirname)
os.chdir(tmpdirname)
subprocess.check_output(
[
shutil.which("func"),
"azure",
"functionapp",
"publish",
self.application_name,
"--python",
"--no-build",
],
env=dict(os.environ, CLI_DEBUG="1"),
)
os.chdir(current_dir)
def update_registration(self):
if not self.create_registration:
return
update_registration(self.application_name)
def done(self):
logger.info(TELEMETRY_NOTICE)
client_secret_arg = (
("--client_secret %s" % self.cli_config["client_secret"])
if "client_secret" in self.cli_config
else ""
)
logger.info(
"Update your CLI config via: onefuzz config --endpoint https://%s.azurewebsites.net --authority %s --client_id %s %s",
self.application_name,
self.cli_config["authority"],
self.cli_config["client_id"],
client_secret_arg,
)
def arg_dir(arg):
if not os.path.isdir(arg):
raise argparse.ArgumentTypeError("not a directory: %s" % arg)
return arg
def arg_file(arg):
if not os.path.isfile(arg):
raise argparse.ArgumentTypeError("not a file: %s" % arg)
return arg
def main():
states = [
("check_region", Client.check_region),
("rbac", Client.setup_rbac),
("arm", Client.deploy_template),
("queues", Client.create_queues),
("eventgrid", Client.create_eventgrid),
("tools", Client.upload_tools),
("instance-specific-setup", Client.upload_instance_setup),
("third-party", Client.upload_third_party),
("api", Client.deploy_app),
("update_registration", Client.update_registration),
]
formatter = argparse.ArgumentDefaultsHelpFormatter
parser = argparse.ArgumentParser(formatter_class=formatter)
parser.add_argument("location")
parser.add_argument("resource_group")
parser.add_argument("application_name")
parser.add_argument("owner")
parser.add_argument(
"--arm-template",
type=arg_file,
default="azuredeploy.json",
help="(default: %(default)s)",
)
parser.add_argument(
"--workbook-data",
type=arg_file,
default="workbook-data.json",
help="(default: %(default)s)",
)
parser.add_argument(
"--app-zip",
type=arg_file,
default="api-service.zip",
help="(default: %(default)s)",
)
parser.add_argument(
"--tools", type=arg_dir, default="tools", help="(default: %(default)s)"
)
parser.add_argument(
"--instance_specific",
type=arg_dir,
default="instance-specific-setup",
help="(default: %(default)s)",
)
parser.add_argument(
"--third-party",
type=arg_dir,
default="third-party",
help="(default: %(default)s)",
)
parser.add_argument("--client_id")
parser.add_argument("--client_secret")
parser.add_argument(
"--start_at",
default=states[0][0],
choices=[x[0] for x in states],
help=(
"Debug deployments by starting at a specific state. "
"NOT FOR PRODUCTION USE. (default: %(default)s)"
),
)
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument(
"--create_pool_registration",
default=False,
type=bool,
help="Create an application registration and/or generate a "
"password for the pool agent (default: False)",
)
args = parser.parse_args()
if shutil.which("func") is None:
logger.error(FUNC_TOOLS_ERROR)
sys.exit(1)
client = Client(
args.resource_group,
args.location,
args.application_name,
args.owner,
args.client_id,
args.client_secret,
args.app_zip,
args.tools,
args.instance_specific,
args.third_party,
args.arm_template,
args.workbook_data,
args.create_pool_registration,
)
if args.verbose:
level = logging.DEBUG
else:
level = logging.WARN
logging.basicConfig(level=level)
logging.getLogger("deploy").setLevel(logging.INFO)
# TODO: using az_cli resets logging defaults. For now, force these
# to be WARN level
if not args.verbose:
for entry in [
"adal-python",
"msrest.universal_http",
"urllib3.connectionpool",
"az_command_data_logger",
"msrest.service_client",
"azure.core.pipeline.policies.http_logging_policy",
]:
logging.getLogger(entry).setLevel(logging.WARN)
if args.start_at != states[0][0]:
logger.warning(
"*** Starting at a non-standard deployment state. "
"This may result in a broken deployment. ***"
)
started = False
for state in states:
if args.start_at == state[0]:
started = True
if started:
state[1](client)
client.done()
if __name__ == "__main__":
main()