diff --git a/community/sql_fci/README b/community/sql_fci/README new file mode 100644 index 000000000..74d38accb --- /dev/null +++ b/community/sql_fci/README @@ -0,0 +1,5 @@ +This is not an official Google Product. + +To deploy with name "example": + +cloud deployment-manager deployments create example --config test_config.yaml --async diff --git a/community/sql_fci/checkpoints.py b/community/sql_fci/checkpoints.py new file mode 100644 index 000000000..da9c23478 --- /dev/null +++ b/community/sql_fci/checkpoints.py @@ -0,0 +1,194 @@ +# Copyright 2017 Google Inc. 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. +"""Configs and Waiters used for checkpointing parts of the deployment. + +This file just provides a series of "checkpoints" for the deployment, +implemented by using a config's URL to block on, and a waiter to block the +creation of the next config. + +Without the context of google's deployment manager system, this file will +probably not make any sense. For the necessary background please see: + +https://cloud.google.com/deployment-manager/docs/ + +for the necessary background. + +NOTE: Imports in this module must be relative, as this module will not have +access to google3 where it will be run. +""" + +import default +import utils + + +_CONFIG_TYPE = "runtimeconfig.v1beta1.config" +_WAITER_TYPE = "runtimeconfig.v1beta1.waiter" + + +def CreateConfigDefinition(name, deps=None): + """Returns a definition of a runtime config. + + Args: + name: the name of the config as a string + deps: a list of strings, each of which is a dependency to wait for. + + Returns: + A definition of a runtime config as a dict + """ + return { + default.NAME: name, + default.METADATA: { + "dependsOn": deps or [] + }, + default.TYPE: _CONFIG_TYPE, + default.PROPERTIES: { + "config": name, + "description": "marker for the beginning of the {} phase".format( + name) + } + } + + +def CreateWaiterDefinition(name, parent, success_num, timeout=1600, deps=None): + """Returns a definition of a runtime waiter. + + Args: + name: the name of the waiter as a string + parent: the name of the config, as a string, that this waiter will watch on. + success_num: the required number of successes before this + waiter completes. + timeout: number of seconds before this waiter will abort. + deps: a list of strings, each of which is a dependency to wait for. + + Returns: + A definition of a runtime waiter as a dict. + """ + return { + default.NAME: name, + default.TYPE: _WAITER_TYPE, + default.METADATA: { + "dependsOn": deps or [] + }, + default.PROPERTIES: { + "parent": "$(ref.{}.name)".format(parent), + "waiter": name, + "timeout": "{}s".format(timeout), + "success": { + "cardinality": { + "number": success_num, + "path": "success" + } + }, + "failure": { + "cardinality": { + "number": 1, + "path": "failure" + } + } + }, + } + + +def GenerateConfig(context): + """Returns a list of configs and waiters for this deployment. + + The configs and waiters define a series of phases that the deployment will + go through. This is a way to "pause" the deployment while some process on + the VMs happens, checks for success, then goes to the next phase. + + The configs here define the phases, and the waiters "wait" for the phases + to be complete. + + The phases are: + CREATE_DOMAIN: the Windows Active Directory node installs and sets up the + Active Directory. + JOIN_DOMAIN: all nodes join the domain set up by the Active Directory node. + CREATE_CLUSTER: creates the failover cluster, enables S2D + INSTALL_FCI: Installs SQL FCI on all non-master nodes. + + Args: + context: the context of the deployment. This is a class that will have + "properties" and "env" dicts containing parameters for the + deployment. + + Returns: + A list of dicts, which are the definitions of configs and waiters. + """ + + num_cluster_nodes = context.properties["num_cluster_nodes"] + deployment = context.env["deployment"] + + create_domain_config_name = utils.ConfigName( + deployment, utils.CREATE_DOMAIN_URL_ENDPOINT) + create_domain_waiter_name = utils.WaiterName( + deployment, utils.CREATE_DOMAIN_URL_ENDPOINT) + + join_domain_config_name = utils.ConfigName( + deployment, utils.JOIN_DOMAIN_URL_ENDPOINT) + join_domain_waiter_name = utils.WaiterName( + deployment, utils.JOIN_DOMAIN_URL_ENDPOINT) + + # This is the list of resources that will be returned to the deployment + # manager so that the deployment manager can create them. Every Item in this + # list will have a dependency on the item before it so that they are created + # in order. + + cluster_config_name = utils.ConfigName( + deployment, utils.CREATE_CLUSTER_URL_ENDPOINT) + cluster_waiter_name = utils.WaiterName( + deployment, utils.CREATE_CLUSTER_URL_ENDPOINT) + + fci_config_name = utils.ConfigName( + deployment, utils.INSTALL_FCI_URL_ENDPOINT) + fci_waiter_name = utils.WaiterName( + deployment, utils.INSTALL_FCI_URL_ENDPOINT) + + resources = [ + CreateConfigDefinition(create_domain_config_name), + CreateWaiterDefinition( + create_domain_waiter_name, + create_domain_config_name, + 1, + deps=[create_domain_config_name]), + CreateConfigDefinition( + join_domain_config_name, + deps=[create_domain_waiter_name]), + CreateWaiterDefinition( + join_domain_waiter_name, + join_domain_config_name, + num_cluster_nodes, + deps=[join_domain_config_name]), + CreateConfigDefinition( + cluster_config_name, + deps=[join_domain_waiter_name]), + CreateWaiterDefinition( + cluster_waiter_name, + cluster_config_name, + 1, + deps=[cluster_config_name]), + CreateConfigDefinition( + fci_config_name, + deps=[cluster_waiter_name]), + CreateWaiterDefinition( + fci_waiter_name, + fci_config_name, + # -1 to account for the fact that the master already set up + # FCI by this point. + (num_cluster_nodes - 1), + deps=[fci_config_name]) + ] + + return { + "resources": resources, + } diff --git a/community/sql_fci/common.py b/community/sql_fci/common.py new file mode 100644 index 000000000..9043e1db2 --- /dev/null +++ b/community/sql_fci/common.py @@ -0,0 +1,235 @@ +# Copyright 2015 Google Inc. 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. +"""Generic simple functions used for python based templage generation.""" + +import re +import sys +import traceback +import default + +import yaml + +RFC1035_RE = re.compile(r'^[a-z][-a-z0-9]{1,61}[a-z0-9]{1}$') + + +class Error(Exception): + """Common exception wrapper for template exceptions.""" + pass + + +def AddDiskResourcesIfNeeded(context): + """Checks context if disk resources need to be added.""" + if default.DISK_RESOURCES in context.properties: + return context.properties[default.DISK_RESOURCES] + else: + return [] + + +def AutoName(base, resource, *args): + """Helper method to generate names automatically based on default.""" + auto_name = '%s-%s' % (base, '-'.join(list(args) + [default.AKA[resource]])) + if not RFC1035_RE.match(auto_name): + raise Error('"%s" name for type %s does not match RFC1035 regex (%s)' % + (auto_name, resource, RFC1035_RE.pattern)) + return auto_name + + +def AutoRef(base, resource, *args): + """Helper method that builds a reference for an auto-named resource.""" + return Ref(AutoName(base, resource, *args)) + + +def OrderedItems(dict_obj): + """Convenient method to yield sorted iteritems of a dictionary.""" + keys = dict_obj.keys() + keys.sort() + for k in keys: + yield (k, dict_obj[k]) + + +def ShortenZoneName(zone): + """Given a string that looks like a zone name, creates a shorter version.""" + geo, coord, number, letter = re.findall(r'(\w+)-(\w+)(\d)-(\w)', zone)[0] + geo = geo.lower() if len(geo) == 2 else default.LOC[geo.lower()] + coord = default.LOC[coord.lower()] + number = str(number) + letter = letter.lower() + return geo + '-' + coord + number + letter + + +def ZoneToRegion(zone): + """Derives the region from a zone name.""" + parts = zone.split('-') + if len(parts) != 3: + raise Error('Cannot derive region from zone "%s"' % zone) + return '-'.join(parts[:2]) + + +def FormatException(message): + """Adds more information to the exception.""" + message = ('Exception Type: %s\n' + 'Details: %s\n' + 'Message: %s\n') % (sys.exc_type, traceback.format_exc(), message) + return message + + +def Ref(name): + return '$(ref.%s.selfLink)' % name + + +def RefGroup(name): + return '$(ref.%s.instanceGroup)' % name + + +def GlobalComputeLink(project, collection, name): + return ''.join([default.COMPUTE_URL_BASE, 'projects/', project, '/global/', + collection, '/', name]) + + +def LocalComputeLink(project, zone, key, value): + return ''.join([default.COMPUTE_URL_BASE, 'projects/', project, '/zones/', + zone, '/', key, '/', value]) + + +def ReadContext(context, prop_key): + return (context.env['project'], context.properties.get('zone', None), + context.properties[prop_key]) + + +def MakeLocalComputeLink(context, key): + project, zone, value = ReadContext(context, key) + if IsComputeLink(value): + return value + else: + return LocalComputeLink(project, zone, key + 's', value) + + +def MakeGlobalComputeLink(context, key): + project, _, value = ReadContext(context, key) + if IsComputeLink(value): + return value + else: + return GlobalComputeLink(project, key + 's', value) + + +def MakeSubnetworkComputeLink(context, key): + project, zone, value = ReadContext(context, key) + region = ZoneToRegion(zone) + return ''.join([default.COMPUTE_URL_BASE, 'projects/', project, '/regions/', + region, '/subnetworks/', value]) + + +def MakeAcceleratorTypeLink(context, accelerator_type): + project = context.env['project'] + zone = context.properties.get('zone', None) + return ''.join([default.COMPUTE_URL_BASE, 'projects/', project, '/zones/', + zone, '/acceleratorTypes/', accelerator_type]) + + +def MakeFQHN(context, name): + return '%s.c.%s.internal' % (name, context.env['project']) + + +# TODO(victorg): Consider moving this method to a different file +def MakeC2DImageLink(name, dev_mode=False): + if IsGlobalProjectShortcut(name) or name.startswith('http'): + return name + else: + if dev_mode: + return 'global/images/%s' % name + else: + return GlobalComputeLink(default.C2D_IMAGES, 'images', name) + + +def IsGlobalProjectShortcut(name): + return name.startswith('projects/') or name.startswith('global/') + + +def IsComputeLink(name): + return (name.startswith(default.COMPUTE_URL_BASE) or + name.startswith(default.REFERENCE_PREFIX)) + + +def GetNamesAndTypes(resources_dict): + return [(d['name'], d['type']) for d in resources_dict] + + +def SummarizeResources(res_dict): + """Summarizes the name of resources per resource type.""" + result = {} + for res in res_dict: + result.setdefault(res['type'], []).append(res['name']) + return result + + +def ListPropertyValuesOfType(res_dict, prop, res_type): + """Lists all the values for a property of a certain type.""" + return [r['properties'][prop] for r in res_dict if r['type'] == res_type] + + +def MakeResource(resource_list, output_list=None): + """Wrapper for a DM template basic spec.""" + content = {'resources': resource_list} + if output_list: + content['outputs'] = output_list + return yaml.dump(content) + + +def TakeZoneOut(properties): + """Given a properties dictionary, removes the zone specific information.""" + + def _CleanZoneUrl(value): + value = value.split('/')[-1] if IsComputeLink(value) else value + return value + + for name in default.VM_ZONE_PROPERTIES: + if name in properties: + properties[name] = _CleanZoneUrl(properties[name]) + if default.ZONE in properties: + properties.pop(default.ZONE) + if default.BOOTDISK in properties: + properties[default.BOOTDISK] = _CleanZoneUrl(properties[default.BOOTDISK]) + if default.DISKS in properties: + for disk in properties[default.DISKS]: + # Don't touch references to other disks + if default.DISK_SOURCE in disk: + continue + if default.INITIALIZEP in disk: + disk_init = disk[default.INITIALIZEP] + if default.DISKTYPE in disk_init: + disk_init[default.DISKTYPE] = _CleanZoneUrl(disk_init[default.DISKTYPE]) + + +def GenerateEmbeddableYaml(yaml_string): + # Because YAML is a space delimited format, we need to be careful about + # embedding one YAML document in another. This function takes in a string in + # YAML format and produces an equivalent YAML representation that can be + # inserted into arbitrary points of another YAML document. It does so by + # printing the YAML string in a single line format. Consistent ordering of + # the string is also guaranteed by using yaml.dump. + yaml_object = yaml.load(yaml_string) + dumped_yaml = yaml.dump(yaml_object, default_flow_style=True) + return dumped_yaml + + +def FormatErrorsDec(func): + """Decorator to format exceptions if they get raised.""" + + def FormatErrorsWrap(context): + try: + return func(context) + except Exception as e: + raise Error(FormatException(e.message)) + + return FormatErrorsWrap diff --git a/community/sql_fci/default.py b/community/sql_fci/default.py new file mode 100644 index 000000000..2deb02f06 --- /dev/null +++ b/community/sql_fci/default.py @@ -0,0 +1,153 @@ +# Copyright 2015 Google Inc. 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. +"""Convinence module to hold default constants for C2D components. + +There should not be any logic in this module. Its purpose is to simplify +analysis of commonly used GCP and properties names and identify the names +that were custom created for these modules. +""" +# Generic constants +C2D_IMAGES = 'click-to-deploy-images' + +# URL constants +COMPUTE_URL_BASE = 'https://www.googleapis.com/compute/v1/' + +# Deploymen Manager constructs +REFERENCE_PREFIX = '$(ref.' + +# Commonly used in properties namespace +AUTO_DELETE = 'autoDelete' +AUTO_CREATE_SUBNETWORKS = 'autoCreateSubnetworks' +BOOT = 'boot' +BOOTDISK = 'bootDiskType' +BOOTDISKSIZE = 'bootDiskSizeGb' +C_IMAGE = 'containerImage' +DC_MANIFEST = 'dcManifest' +DEPLOYMENT = 'DEPLOYMENT' # used in the deployment coordinator +DISK_NAME = 'diskName' +DISK_RESOURCES = 'addedDiskResources' +DISK_SOURCE = 'source' +ENDPOINT_NAME = 'serviceRegistryEndpointName' +FIXED_GCLOUD = 'fixedGcloud' +GENERATED_PROP = 'generatedProperties' +INITIALIZEP = 'initializeParams' +INSTANCE_NAME = 'instanceName' +IP_ADDRESS = 'IPAddress' +IP_CIDR_RANGE = 'ipCidrRange' +LOCAL_SSD = 'localSSDs' +MAX_NUM = 'maxNumReplicas' +NETWORKS = 'networks' +NO_SCOPE = 'noScope' +PROVIDE_BOOT = 'provideBoot' +REPLICAS = 'replicas' +SIZE = 'size' +TCP_HEALTH_CHECK = 'tcpHealthCheck' +VM_COPIES = 'numberOfVMReplicas' +ZONES = 'zones' + +# Common properties values (only official GCP values allowed here) +EXTERNAL = 'External NAT' +ONE_NAT = 'ONE_TO_ONE_NAT' + +# Common 1st level properties (only official GCP names allowed here) +ACCESS_CONFIGS = 'accessConfigs' +ALLOWED = 'allowed' +BACKENDS = 'backends' +CAN_IP_FWD = 'canIpForward' +CONTAINER = 'container' +DCKRENV = 'dockerEnv' +DCKRIMAGE = 'dockerImage' +DEFAULT_SERVICE = 'defaultService' +DEVICE_NAME = 'deviceName' +DISKS = 'disks' +DISK_SIZE = 'diskSizeGb' +DISKTYPE = 'diskType' +GUEST_ACCELERATORS = 'guestAccelerators' +HEALTH_CHECKS = 'healthChecks' +HEALTH_PATH = 'healthPath' +HOST_RULES = 'hostRules' +IP_PROTO = 'IPProtocol' +LB_SCHEME = 'loadBalancingScheme' +MACHINETYPE = 'machineType' +METADATA = 'metadata' +NAME = 'name' +NETWORK = 'network' +NETWORK_INTERFACES = 'networkInterfaces' +NETWORKIP = 'networkIP' +SUBNETWORK = 'subnetwork' +PATH_MATCHERS = 'pathMatchers' +PROPERTIES = 'properties' +PORT = 'port' +PORTS = 'ports' +PROTOCOL = 'protocol' +PROJECT = 'project' +REGION = 'region' +SERVICE = 'service' +SERVICE_ACCOUNTS = 'serviceAccounts' +SIZE_GB = 'sizeGb' +SRCIMAGE = 'sourceImage' +SRC_RANGES = 'sourceRanges' +TAGS = 'tags' +TYPE = 'type' +VM_TEMPLATE = 'instanceTemplate' +ZONE = 'zone' + +# Zone specific VM properties +VM_ZONE_PROPERTIES = [DISKTYPE, MACHINETYPE, BOOTDISK] + +# Resource type defaults names +ADDRESS = 'compute.v1.address' +AUTOSCALER = 'compute.v1.autoscaler' +BACKEND_SERVICE = 'compute.v1.backendService' +CONFIG = 'runtimeconfig.v1beta1.config' +DISK = 'compute.v1.disk' +ENDPOINT = 'serviceregistry.v1alpha.endpoint' +FIREWALL = 'compute.v1.firewall' +FORWARDING_RULE = 'compute.v1.forwardingRule' +GF_RULE = 'compute.v1.globalForwardingRule' +HEALTHCHECK = 'compute.v1.httpHealthCheck' +IGM = 'compute.v1.instanceGroupManager' +INSTANCE = 'compute.v1.instance' +INSTANCE_GROUP = 'compute.v1.instanceGroup' +NETWORK_TYPE = 'compute.v1.network' +PROXY = 'compute.v1.targetHttpProxy' +REGION_BACKEND_SERVICE = 'compute.v1.regionBackendService' +SUBNETWORK_TYPE = 'compute.v1.subnetwork' +TEMPLATE = 'compute.v1.instanceTemplate' +URL_MAP = 'compute.v1.urlMap' +WAITER = 'runtimeconfig.v1beta1.waiter' + +# Also Known As constants +AKA = { + AUTOSCALER: 'as', + BACKEND_SERVICE: 'bes', + DISK: 'disk', + FIREWALL: 'fwall', + GF_RULE: 'ip', + HEALTHCHECK: 'hc', + INSTANCE: 'vm', + PROXY: 'tproxy', + IGM: 'igm', + URL_MAP: 'umap', +} + +LOC = { + 'europe': 'eu', + 'asia': 'as', + 'central': 'c', + 'east': 'e', + 'west': 'w', + 'north': 'n', + 'south': 's', +} diff --git a/community/sql_fci/install.ps1 b/community/sql_fci/install.ps1 new file mode 100644 index 000000000..ce29dc08f --- /dev/null +++ b/community/sql_fci/install.ps1 @@ -0,0 +1,1124 @@ +#Copyright 2017 Google, Inc. All Rights Reserved. + +<# +.SYNOPSIS + script to set up nodes in an SQL FCI deployment. +.DESCRIPTION + This script will perform the setup required to run an SQL FCI + deployment based on the metadata. There are 3 roles with different + (but overlapping) setup processes. + AD: Active Directory controller + Master: The first cluster node. This node will be used to install the + Fail over cluster and enable S2D + Cluster: All other cluster nodes. +#> + + +function WaitFor-Agent { + <# + .SYNOPSIS + Wait for the windows agent to come up so that we can access + the metadata + #> + $expire_time = (Get-Date) + (New-TimeSpan -Seconds 120) + while (!(Get-Process GCEWindowsAgent -ErrorAction SilentlyContinue)) { + if ((Get-Date) -gt $expire_time) { + Write-Error 'Windows Agent failed to start.' + } + Write-Host 'Waiting for windows agent...' + Start-Sleep 5 + } +} + + +# We are using the fact that the agent is running to signal that windows +# has booted up to the point of running services, implying that updates +# are finished. +WaitFor-Agent + + +# Define constants here. +$script:run_time_base = 'https://runtimeconfig.googleapis.com/v1beta1' +$script:domain_mode = 'Win2012R2' +$script:forest_mode = 'Win2012R2' +$script:access_token_path = 'C:\Program Files\Google\Compute Engine\access_token.txt' +$script:addsforest_flag = 'C:\Program Files\Google\Compute Engine\adds_forest_install.txt' +$script:joined_flag = 'C:\Program Files\Google\Compute Engine\domain_joined.txt' +$script:complete_flag = 'C:\Program Files\Google\Compute Engine\SQLFCI_set_up_complete.txt' +$script:volume_location = 'C:\ClusterStorage\Volume1' + + +function Get-Metadata { + <# + .SYNOPSIS + Retrieve metadata from the path specified. + .DESCRIPTION + Retrieve metadata from the path specified. The metadata could + come as either an array of bytes, or a string. In case it is + an array of bytes, convert it to a string before returning. + #> + param ( + [Parameter(Mandatory=$true)] + $Path + ) + + $metadata_base_url = 'http://metadata.google.internal/computeMetadata/v1/' + $content = (Invoke-WebRequest -Uri "${metadata_base_url}${Path}" ` + -Headers @{'Metadata-Flavor'='Google'} ` + -UseBasicParsing).content + if ($content -is [System.Array]) { + return [System.Text.Encoding]::ASCII.GetString($content) + } + else { + return $content + } +} + + +# Gather the necessary data from the metadata. +$script:safe_mode_password = Get-Metadata -Path 'instance/attributes/safe-password' +$script:ad_url= Get-Metadata -Path 'instance/attributes/create-domain-config-url' +$script:join_domain_url = Get-Metadata -Path 'instance/attributes/join-domain-config-url' +$script:create_cluster_url = Get-Metadata -Path 'instance/attributes/create-cluster-config-url' +$script:install_fci_url = Get-Metadata -Path 'instance/attributes/install-fci-config-url' +$script:domain = Get-Metadata -Path 'instance/attributes/ad-domain' +$script:domain_netbios = Get-Metadata -Path 'instance/attributes/domain-netbios' +$script:is_ad_node = Get-Metadata -Path 'instance/attributes/is-ad-node' +$script:is_master = Get-Metadata -Path 'instance/attributes/is-master' +$script:ad_node_ip = Get-Metadata -Path 'instance/attributes/ad-node-ip' +$script:service_account = Get-Metadata -Path 'instance/attributes/service-account' +$script:service_password = Get-Metadata -Path 'instance/attributes/service-password' +$script:node_name = Get-Metadata -Path 'instance/attributes/my-node-name' +$script:ad_node_name = Get-Metadata -Path 'instance/attributes/ad-node-name' +$script:all_nodes = Get-Metadata -Path 'instance/attributes/all-nodes' +$script:cluster_ip = Get-Metadata -Path 'instance/attributes/cluster-ip' +$script:application_ip = Get-Metadata -Path 'instance/attributes/application-ip' +$script:zone = Get-Metadata -Path 'instance/attributes/zone' +$script:instance_group = Get-Metadata -Path 'instance/attributes/instance-group' +$script:health_check_port= Get-Metadata -Path 'instance/attributes/wsfc-agent-port' +$script:is_test = Get-Metadata -Path 'instance/attributes/is-test' + +$ErrorActionPreference = 'Stop' + +# Interpolated string that will be used as the SQL config file on the +# master node +$script:sql_ini_master = @" +;SQL Server 2016 Configuration File +[OPTIONS] + +; Specifies a Setup work flow, like INSTALL, UNINSTALL, or UPGRADE. This is a required parameter. + +ACTION="InstallFailoverCluster" + +; Specifies that SQL Server Setup should not display the privacy statement when ran from the command line. + +SUPPRESSPRIVACYSTATEMENTNOTICE="False" + +; By specifying this parameter and accepting Microsoft R Open and Microsoft R Server terms, you acknowledge that you have read and understood the terms of use. + +IACCEPTROPENLICENSETERMS="True" +IAcceptSQLServerLicenseTerms="True" + +; Use the /ENU parameter to install the English version of SQL Server on your localized Windows operating system. + +ENU="True" + +; Setup will not display any user interface. + +QUIET="True" + +; Specify whether SQL Server Setup should discover and include product updates. The valid values are True and False or 1 and 0. By default SQL Server Setup will include updates that are found. + +UpdateEnabled="True" + +; If this parameter is provided, then this computer will use Microsoft Update to check for updates. + +USEMICROSOFTUPDATE="False" + +; Specifies features to install, uninstall, or upgrade. The list of top-level features include SQL, AS, RS, IS, MDS, and Tools. The SQL feature will install the Database Engine, Replication, Full-Text, and Data Quality Services (DQS) server. The Tools feature will install shared components. + +FEATURES=SQLENGINE,REPLICATION,FULLTEXT,DQ + +; Specify the location where SQL Server Setup will obtain product updates. The valid values are "MU" to search Microsoft Update, a valid folder path, a relative path such as .\MyUpdates or a UNC share. By default SQL Server Setup will search Microsoft Update or a Windows Update service through the Window Server Update Services. + +UpdateSource="MU" + +; Displays the command line parameters usage + +HELP="False" + +; Specifies that the detailed Setup log should be piped to the console. + +INDICATEPROGRESS="False" + +; Specifies that Setup should install into WOW64. This command line argument is not supported on an IA64 or a 32-bit system. + +X86="False" + +; Specify a default or named instance. MSSQLSERVER is the default instance for non-Express editions and SQLExpress for Express editions. This parameter is required when installing the SQL Server Database Engine (SQL), Analysis Services (AS), or Reporting Services (RS). + +INSTANCENAME="MSSQLSERVER" + +; Specify the root installation directory for shared components. This directory remains unchanged after shared components are already installed. + +INSTALLSHAREDDIR="C:\Program Files\Microsoft SQL Server" + +; Specify the root installation directory for the WOW64 shared components. This directory remains unchanged after WOW64 shared components are already installed. + +INSTALLSHAREDWOWDIR="C:\Program/ Files (x86)\Microsoft SQL Server" + +; Specify the Instance ID for the SQL Server features you have specified. SQL Server directory structure, registry structure, and service names will incorporate the instance ID of the SQL Server instance. + +INSTANCEID="MSSQLSERVER" + +; Specify the installation directory. + +INSTANCEDIR="C:\Program Files\Microsoft SQL Server" + +; Specifies a cluster shared disk to associate with the SQL Server failover cluster instance. + +FAILOVERCLUSTERDISKS="Cluster Virtual Disk (VDisk01)" + +; Specifies the name of the cluster group for the SQL Server failover cluster instance. + +FAILOVERCLUSTERGROUP="SQL Server (MSSQLSERVER)" + +; Specifies an encoded IP address. The encodings are semicolon-delimited (;), and follow the format ;
;;. Supported IP types include DHCP, IPV4, and IPV6. + +FAILOVERCLUSTERIPADDRESSES="IPv4;$script:application_ip;Cluster Network 1;255.255.255.0" + +; Specifies the name of the SQL Server failover cluster instance. This name is the network name that is used to connect to SQL Server services. + +FAILOVERCLUSTERNETWORKNAME="SQL2016FCI" + +; Agent account name + +AGTSVCACCOUNT="$script:domain_netbios\$script:service_account" +AGTSVCPASSWORD="$script:service_password" + +; CM brick TCP communication port + +COMMFABRICPORT="0" + +; How matrix will use private networks + +COMMFABRICNETWORKLEVEL="0" + +; How inter brick communication will be protected + +COMMFABRICENCRYPTION="0" + +; TCP port used by the CM brick + +MATRIXCMBRICKCOMMPORT="0" + +; Level to enable FILESTREAM feature at (0, 1, 2 or 3). + +FILESTREAMLEVEL="0" + +; Specifies a Windows collation or an SQL collation to use for the Database Engine. + +SQLCOLLATION="SQL_Latin1_General_CP1_CI_AS" + +; Account for SQL Server service: Domain\User or system account. + +SQLSVCACCOUNT="$script:domain_netbios\$script:service_account" +SQLSVCPASSWORD="$script:service_password" + +; Set to "True" to enable instant file initialization for SQL Server service. If enabled, Setup will grant Perform Volume Maintenance Task privilege to the Database Engine Service SID. This may lead to information disclosure as it could allow deleted content to be accessed by an unauthorized principal. + +SQLSVCINSTANTFILEINIT="True" + +; Windows account(s) to provision as SQL Server system administrators. + +SQLSYSADMINACCOUNTS="$script:domain_netbios\$script:service_account" + +; The number of Database Engine TempDB files. + +SQLTEMPDBFILECOUNT="4" + +; Specifies the initial size of a Database Engine TempDB data file in MB. + +SQLTEMPDBFILESIZE="8" + +; Specifies the automatic growth increment of each Database Engine TempDB data file in MB. + +SQLTEMPDBFILEGROWTH="64" + +; Specifies the initial size of the Database Engine TempDB log file in MB. + +SQLTEMPDBLOGFILESIZE="8" + +; Specifies the automatic growth increment of the Database Engine TempDB log file in MB. + +SQLTEMPDBLOGFILEGROWTH="64" + +; The Database Engine root data directory. + +INSTALLSQLDATADIR="$script:volume_location" + +; Add description of input argument FTSVCACCOUNT + +FTSVCACCOUNT="NT Service\MSSQLFDLauncher" +"@ + + +# Interpolated string that will be used as the SQL config file for cluster +# nodes. +$script:sql_ini = @" +;SQL Server 2016 Configuration File +[OPTIONS] + +; Specifies a Setup work flow, like INSTALL, UNINSTALL, or UPGRADE. This is a required parameter. + +ACTION="AddNode" + +; Specifies that SQL Server Setup should not display the privacy statement when ran from the command line. + +SUPPRESSPRIVACYSTATEMENTNOTICE="False" + +; By specifying this parameter and accepting Microsoft R Open and Microsoft R Server terms, you acknowledge that you have read and understood the terms of use. + +IACCEPTROPENLICENSETERMS="True" +IAcceptSQLServerLicenseTerms="True" + +; Use the /ENU parameter to install the English version of SQL Server on your localized Windows operating system. + +ENU="True" + +; Setup will not display any user interface. + +QUIET="True" + +; Specify whether SQL Server Setup should discover and include product updates. The valid values are True and False or 1 and 0. By default SQL Server Setup will include updates that are found. + +UpdateEnabled="True" + +; If this parameter is provided, then this computer will use Microsoft Update to check for updates. + +USEMICROSOFTUPDATE="False" + +; Specify the location where SQL Server Setup will obtain product updates. The valid values are "MU" to search Microsoft Update, a valid folder path, a relative path such as .\MyUpdates or a UNC share. By default SQL Server Setup will search Microsoft Update or a Windows Update service through the Window Server Update Services. + +UpdateSource="MU" + +; Displays the command line parameters usage + +HELP="False" + +; Specifies that the detailed Setup log should be piped to the console. + +INDICATEPROGRESS="True" + +; Specifies that Setup should install into WOW64. This command line argument is not supported on an IA64 or a 32-bit system. + +X86="False" + +; Specify a default or named instance. MSSQLSERVER is the default instance for non-Express editions and SQLExpress for Express editions. This parameter is required when installing the SQL Server Database Engine (SQL), Analysis Services (AS), or Reporting Services (RS). + +INSTANCENAME="MSSQLSERVER" + +; Specifies the name of the cluster group for the SQL Server failover cluster instance. + +FAILOVERCLUSTERGROUP="SQL Server (MSSQLSERVER)" + +; Indicates that the change in IP address resource dependency type for the SQL Server multi-subnet failover cluster is accepted. + +CONFIRMIPDEPENDENCYCHANGE="False" + +; Specifies an encoded IP address. The encodings are semicolon-delimited (;), and follow the format ;
;;. Supported IP types include DHCP, IPV4, and IPV6. + +FAILOVERCLUSTERIPADDRESSES="IPv4;$script:application_ip;Cluster Network 1;255.255.255.0" + +; Specifies the name of the SQL Server failover cluster instance. This name is the network name that is used to connect to SQL Server services. + +FAILOVERCLUSTERNETWORKNAME="SQL2016FCI" + +; Agent account name + +AGTSVCACCOUNT="$script:domain_netbios\$script:service_account" +AGTSVCPASSWORD="$script:service_password" + +; Account for SQL Server service: Domain\User or system account. + +SQLSVCACCOUNT="$script:domain_netbios\$script:service_account" +SQLSVCPASSWORD="$script:service_password" + +; Set to "True" to enable instant file initialization for SQL Server service. If enabled, Setup will grant Perform Volume Maintenance Task privilege to the Database Engine Service SID. This may lead to information disclosure as it could allow deleted content to be accessed by an unauthorized principal. + +SQLSVCINSTANTFILEINIT="True" + +; Add description of input argument FTSVCACCOUNT + +FTSVCACCOUNT="NT Service\MSSQLFDLauncher" +"@ + + +function Get-AccessToken { + <# + .SYNOPSIS + Gets the access token for use in web requests. + .DESCRIPTION + Gets the access token for use in web requests. This access token has + an expiration date so is only useful during the execution of this + script. + #> + if (!(Test-Path $script:access_token_path)) { + $access_token = (Get-Metadata -Path 'instance/service-accounts/default/token' | ConvertFrom-Json).access_token + $access_token | Set-Content $script:access_token_path + } + + return (Get-Content $script:access_token_path) +} + + + +function Check-RuntimeConfigUrl { + <# + .SYNOPSIS + Checks if a config URL exists. + .DESCRIPTION + Checks if a config URL exists and returns true or false. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $url + ) + $access_token = Get-AccessToken + $header = @{'Authorization'="Bearer $access_token"} + + try { + if (Invoke-RestMethod -Uri $url -Method GET -Headers $header) { + return $true + } + } catch { + Write-Host $_ + Write-Host 'Runtime config URL not yet Accessible' + return $false + } + return $false +} + + +function WaitFor-RuntimeReady { + <# + .SYNOPSIS + Waits for a config URL to exist. + .DESCRIPTION + Repeatedly checks the given config URL to see if it exists. This can + be used to block execution of this script until a a config is ready. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $timeout, + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $url + ) + $expire_time = (Get-Date) + (New-TimeSpan -Seconds $timeout) + while ((Get-Date) -lt $expire_time) { + Write-Host("Checking if $url is accessible...") + if (Check-RuntimeConfigUrl -url $url) { + Write-Host("SUCCESS! $url is accessible") + return $true + } + $sleep_time = 10 + Write-Host ("$url is not accessible. Retrying in $sleep_time seconds.") + Start-Sleep -s $sleep_time + } + Write-Host ("Not able to access $url. Aborting.") + return $false +} + + +function Mark-RuntimeDone { + <# + .SYNOPSIS + POSTs a variable to a config. + .DESCRIPTION + POSTS a variable to a config depending on the "result". These + variables are monitored by runtime waiters, which will pass + or fail (or continue waiting) depending on the contents posted. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $url, + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $result + ) + $path = "$url/variables" + $config_name = (($path -split "$Script:run_time_base/")[1]) + Write-Host ("Marking $config_name as $result") + $variable = @{ + name = "$config_name/$result/$script:node_name" + } + $var_json = $variable | ConvertTo-Json + $access_token = Get-AccessToken + $header = @{'Authorization'="Bearer $access_token"} + + $content_type = 'application/json' + + # Retry for a minute. posting to the runtime variables can be flaky. + $expire_time = (Get-Date) + (New-TimeSpan -Seconds 60) + while ($true) { + try { + $resp = Invoke-RestMethod -Uri $path ` + -ContentType $content_type ` + -Method POST ` + -Body $var_json ` + -Headers $header ` + -ErrorAction SilentlyContinue + break + } catch { + Write-Host $_ + if ((Get-Date) -lt $expire_time) { + throw 'failed to mark the runtime config' + } + Write-Host 'Retrying...' + } + } + Write-Host ($resp) +} + + +function Add-Account { + <# + .SYNOPSIS + Add the service account to the admin group. + #> + net user $script:service_account $script:service_password /add /expires:never + net localgroup administrators $script:service_account /add +} + + +function Setup-Ad { + <# + .SYNOPSIS + Installs AD on this node. + .DESCRIPTION + Installs AD on this node. This is a 2 step process: + Step 1) Install AD, then restart. + Step 2) After restart, Verify the Domain is installed then mark the + runtime as done. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $flag + ) + $flag = 'C:\Program Files\Google\Compute Engine\sysprep\adds_forest_install.txt' + if (Test-Path $flag) { + # AD has already been installed, and we are coming up from a reboot. + while (-not $ad_node) { + try { + $ad_node = Get-ADDomainController -Discover -Domain $script:domain -ErrorAction stop + } catch { + Write-Host $_ + Write-Host 'Retrying in 10 seconds.' + Start-Sleep -second 10 + } + } + Write-Host ('AD is now set up.') + Mark-RuntimeDone -url $script:ad_url -result 'success' + Write-Host ('AD runtime is now notified. Adding service accounts...') + Add-Account + Write-Host ('Service accounts added.') + Write-Host ('Now running as AD Domain controller.') + } + else { + # We are comming up for the first time. Install AD. + + Install-WindowsFeature -name AD-Domain-Services -IncludeManagementTools + Import-Module ADDSDeployment + + # Mark the flag so that we know across reboots that AD has been installed. + 'Running Install-ADDSForest.' | Set-Content $flag + + MKDIR C:\SQLBackup + New-SMBShare -Name 'SQLBackup' -Path 'c:\SQLBackup' -FullAccess 'Everyone' + MKDIR C:\QWitness + New-SMBShare -Name 'QWitness' -Path 'c:\QWitness' -FullAccess 'Everyone' + + ipconfig /registerdns + net user Administrator $script:safe_mode_password + Install-ADDSForest -CreateDnsDelegation:$false ` + -DatabasePath 'C:\Windows\NTDS' ` + -DomainName $script:domain ` + -DomainNetBIOSName $script:domain_netbios ` + -DomainMode $script:domain_mode ` + -ForestMode $script:forest_mode ` + -InstallDNS:$true ` + -LogPath 'C:\Windows\NTDS' ` + -SYSVOLPath 'C:\Windows\SYSVOL' ` + -Force:$true ` + -SafeModeAdministratorPassword (ConvertTo-SecureString $script:safe_mode_password -AsPlainText -Force) + # After setting up an AD the initial time settings are lost + w32tm /config /manualpeerlist:'metadata.google.internal' ` + /syncfromflags:manual /reliable:yes /update + Restart-Service w32time + + #The VM will reboot at this point... + } +} + + +function Setup-AdNode { + <# + .SYNOPSIS + Overall set up for the AD node. + .DESCRIPTION + #> + Write-Host ('Waiting for the AD set up runtime to be available...') + WaitFor-RuntimeReady -url $script:ad_url -timeout 60 + Write-Host ('AD runtime ready. Setting up AD...') + Setup-Ad -flag $script:addsforest_flag +} + + +function Check-JoinedDomain { + <# + .SYNOPSIS + Verifies that this node has successfully joined the domain. + .DESCRIPTION + Verifies that this node has successfully joined the domain by + executing an arbitrary command as the domain user. + #> + try { + $cred = New-Object System.Management.Automation.PSCredential( + "$script:domain_netbios\$script:service_account", + ($script:service_password | ConvertTo-SecureString -AsPlainText -Force)) + Start-Process -Credential $cred cmd.exe -ArgumentList @('/c', 'exit') + Write-Host ('Successfully joined domain') + Mark-RuntimeDone -url $script:join_domain_url -result 'success' + return $true + } catch { + Write-Host $_ + Write-Host ('Failed to join domain') + Mark-RuntimeDone -url $script:join_domain_url -result 'failure' + return $false + } +} + + +function Join-InstanceGroup { + gcloud compute instance-groups unmanaged add-instances ` + $script:instance_group ` + --instances $script:node_name ` + --zone $script:zone +} + + +function Join-Domain { + <# + .SYNOPSIS + Adds this computer to the domain at $script:ad_node_ip. + .DESCRIPTION + Adds this computer to the domain at $script:ad_node_ip. Assumes that + the service account information is retrieved from the metadata and set + globally. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $flag + ) + try { + Write-Host ('Waiting for the AD to be ready.') + WaitFor-RuntimeReady -url $script:join_domain_url -timeout 1600 + Write-Host ('Ready to join domain') + $adapter = (Get-NetAdapter).Name + & netsh interface ip set dnsservers name="${adapter}" source=static address=$script:ad_node_ip + & ipconfig /flushdns + $cred = New-Object System.Management.Automation.PSCredential( + "$script:domain_netbios\$script:service_account", + ($script:service_password | ConvertTo-SecureString -AsPlainText -Force)) + Add-Computer -DomainName $script:domain -Force -Credential $cred + & net localgroup administrators "$script:domain_netbios\$script:service_account" /add + Start-Process -Credential $cred cmd.exe -ArgumentList @('/c', 'exit') + Write-Host $_ + Write-Host ('Domain joined. Restarting...') + 'DOMAIN JOINED!!!' | Set-Content $flag + Restart-Computer + } catch { + Write-Host $_ + Write-Host ('Failed to join domain') + Mark-RuntimeDone -url $script:join_domain_url -result 'failure' + } +} + + +function Start-NewCluster { + <# + .SYNOPSIS + Starts a new scheduled task to create a new fail over cluster. + #> + try { + $command = "New-Cluster -Name wsfc_cluster -Node $script:all_nodes -NoStorage -StaticAddress $script:cluster_ip | " + ` + "Set-ClusterQuorum -FileShareWitness \\$script:ad_node_name\QWitness" + Invoke-ScheduleTask -Execute 'powershell.exe' ` + -Argument $command ` + -TaskName 'start_new_cluster' + } catch { + Mark-RuntimeDone -url $script:create_cluster_url -result 'failure' + Write-Host $_ + Write-Host ('Creating new cluster has failed') + } +} + + +function InvokeAs-Service { + <# + .SYNOPSIS + Invokes a script as the service account + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $scriptblock, + [Parameter(ValueFromPipelineByPropertyName=$true)] + $argumentlist + ) + $cred = New-Object System.Management.Automation.PSCredential( + "$script:domain_netbios\$script:service_account", + ($script:service_password | ConvertTo-SecureString -AsPlainText -Force)) + $session_options = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + $sess = New-PSSession -Credential $cred -SessionOption $session_options + + if ($argumentlist -ne $null) { + Invoke-Command -Session $sess -ScriptBlock $scriptblock -ArgumentList $argumentlist + } + else { + Invoke-Command -Session $sess -ScriptBlock $scriptblock + } +} + + +function WaitFor-Cluster { + <# + .SYNOPSIS + Blocks until cluster is available. + .DESCRIPTION + This function will block until the cluster deployment is available. + It will query the cluster, with the service account credentials, in + a loop until the query returns success. + #> + + # Simple script to block until the cluster is ready OR timeout + $wait_for_cluster_script = { + $expire_time = (Get-Date) + (New-TimeSpan -Seconds 600) + while ($true) { + $cluster_info = Get-Cluster -ErrorVariable Err -ErrorAction SilentlyContinue + if ($Err) { + Write-Host $Err + if ((Get-Date) -gt $expire_time) { + throw 'Failed to get cluster information' + } + Start-Sleep 2 + } + else { + break + } + } + } + Write-Host ('Waiting for cluster to become available.') + try { + InvokeAs-Service -ScriptBlock $wait_for_cluster_script + } catch { + Write-Host $_ + Write-Host 'The cluster has failed to start.' + Mark-RuntimeDone -url $script:create_cluster_url -result 'failure' + } +} + + +function Enable-S2D { + <# + .SYNOPSIS + Enables S2D. + .DESCRIPTION + Enables 2D on the cluster with the service account credentials. + Because this is the last step in the cluster install phase of the + deployment, it will mark the cluster config either success or failure. + #> + [long] $volume_size_gb = Get-Metadata -Path 'instance/attributes/volume-size-gb' + $script_enable_s2d = { + param ( + [Parameter(Mandatory=$true)] + [long]$volume_size_gb + ) + # convert to bytes + $volume_size = $volume_size_gb * 1GB + Write-Host "$(Get-Date) enable s2d" + Enable-CLusterS2D -Verbose -Confirm:$false + New-Volume -StoragePoolFriendlyName S2D* ` + -FriendlyName VDisk01 ` + -FileSystem CSVFS_REFS ` + -Size $volume_size + } + try { + InvokeAs-Service -ScriptBlock $script_enable_s2d -ArgumentList ($volume_size_gb) + Write-Host ('Successfully set up S2D.') + } catch { + Write-Host $_ + Write-Host ('Failed to set up S2D.') + Mark-RuntimeDone -url $script:create_cluster_url -result 'failure' + } + +} + + +function Clean-SQL { + <# + .SYNOPSIS + Uninstall SQL server from the system. + .DESCRIPTION + It is not possible to upgrade an existing SQL server to an + SQL FCI node. Therefore we have to uninstall and re-install. + This is not a required step for the "Join Domain" phase of the + deployment, however because a restart is required for the + SQL uninstall and also to join the domain, we can reduce the + overall deployment by only restarting once after both these + steps are conplete. + #> + try { + C:\sql_server_install\Setup.exe /Action=Uninstall ` + /FEATURES=SQL,AS,IS,RS ` + /INSTANCENAME=MSSQLSERVER /Q + Write-Host ('sql server uninstalled') + } catch { + Write-Host $_ + Write-Host('Failed to uninstall sql server') + Mark-RuntimeDone -url $script:join_domain_url -result 'failure' + } +} + + +function Invoke-ScheduleTask { + <# + .SYNOPSIS + Execute a scheduled task and block until the task is in 'Ready' state. + #> + param ( + [parameter(Mandatory=$true)] + [String]$Execute, + [parameter(Mandatory=$true)] + [String]$Argument, + [parameter(Mandatory=$true)] + [String]$TaskName + ) + + $action = New-ScheduledTaskAction -Execute "`"${Execute}`"" ` + -Argument $Argument + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -User $script:domain_netbios\$script:service_account ` + -Password $script:service_password ` + -RunLevel Highest + + Start-ScheduledTask -TaskName $TaskName + $timeout_seconds = 600 + $expire_time = (Get-Date) + (New-TimeSpan -Seconds $timeout_seconds) + while ((Get-ScheduledTask -TaskName $TaskName).State -ne 'Ready') { + if ((Get-Date) -gt $expire_time) { + Write-Host "Task did not complete after $timeout_seconds" + break + } + Write-Output 'Waiting on scheduled task...' + Start-Sleep -Seconds 10 + } + + $task_result = (schtasks /query /FO LIST /V /TN $TaskName | + findstr 'Result').Split()[-1] + Write-Host "Task result: ${task_result}" + + if ($task_result -ne '0') { + throw "Failed to execute ${TaskName}: $Execute $Argument" + } +} + + +function Setup-FCI { + <# + .SYNOPSIS + Installs SQL FCI on this node. + .DESCRIPTION + Installs SQL FCI on this node by first checking that S2D is enabled, + then installing SQL with an ini file that is set up for FCI. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $config_contents + ) + Write-Host ('Setting up FCI') + & netsh advfirewall firewall add rule name='Open Port 1433 for sql' ` + dir=in action=allow protocol=TCP localport=1433 + + Write-Host "$(Get-Date) Wait for S2D online" + $expire_time = (Get-Date) + (New-TimeSpan -Seconds 600) + while (!(Test-Path $script:volume_location)) { + if ((Get-Date) -gt $expire_time) { + throw 'S2D failed to come online.' + } + Write-Host ('S2D not yet ready') + Start-Sleep -Seconds 10 + } + + Write-Host ('S2d is online') + $config_file = 'C:\Program Files\Google\Compute Engine\sql_config.ini' + $config_contents -replace '\n', "`r`n" | Out-File -FilePath $config_file ` + -Encoding ASCII + + Invoke-ScheduleTask -Execute 'powershell' ` + -Argument '"Test-Cluster -Include ''Storage Spaces Direct''"' ` + -TaskName 'verify_cluster' + + $sql_install_path = 'C:\sql_server_install\Setup.exe' + Invoke-ScheduleTask -Execute $sql_install_path ` + -Argument "/CONFIGURATIONFILE=`"${config_file}`"" ` + -TaskName 'install_sql' + $install_log = Get-Childitem 'C:\Program Files\Microsoft SQL Server\*\Setup Bootstrap\Log\Summary.txt' + Get-Content $install_log.FullName | Write-Host +} + + +function Prepare-Instance { + <# + .SYNOPSIS + Common steps to take for cluster nodes. + .DESCRIPTION + This function executes a few things that are necessary for the rest + of the deployment process to work. It will: + 1) remove the current version of SQL. The FCI version SQL requires + that no previous version of SQL is present. There is no way to + update an SQL installation to an FCI SQL installation. + 2) Join the load balancers instance group. + 3) Join the AD domain. + .PARAMETER domain_joined_flag + File path that, if exists, signifies that the domain should already + have been joined. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + [Alias('flag')] + $domain_joined_flag + ) + & netsh advfirewall firewall add rule ` + name="Open Port $script:health_check_port for health check" ` + dir=in action=allow protocol=TCP localport=$script:health_check_port + Clean-SQL + Join-InstanceGroup + Join-Domain -flag $domain_joined_flag +} + + +<# + # The "master_sql" text and Setup-MasterTest function are used in testing only. + # This is required for setting up a test database to make queries to. + #> +$script:master_sql = @' +USE [master] +GO +EXEC xp_instance_regwrite N'HKEY_LOCAL_MACHINE', N'Software\Microsoft\MSSQLServer\MSSQLServer', N'LoginMode', REG_DWORD, 2 +GO + +USE [master] +GO +ALTER LOGIN [sa] WITH PASSWORD = 'remoting@123' +GO +ALTER LOGIN [sa] ENABLE +GO + + +!!NET STOP MSSQLSERVER /y +!!NET STOP SQLSERVERAGENT /y + +!!NET START MSSQLSERVER /y +!!NET START SQLSERVERAGENT /y +'@ + + +function Setup-MasterTest { + + Write-Output "$(Get-Date) Config SQL instance" + $sql_config = 'C:\Program Files\Google\Compute Engine\sql_config.sql' + Set-Content -Path $sql_config -Value $master_sql + + # Give a little window for SQL to properly start up and be ready for requests + Start-Sleep 30 + $sql_cmd = Get-Childitem 'C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\*\Tools\Binn\SQLCMD.exe' + Invoke-ScheduleTask -Execute $sql_cmd.FullName ` + -Argument "-S localhost -i `"${sql_config}`"" ` + -TaskName 'config_test_sql' + Restart-Service -Force MSSQLSERVER +} + +<# End of test-only code #> + + +function Setup-MasterNode { + <# + .SYNOPSIS + Overall set up for a cluster master node. + .DESCRIPTION + Sets up the master cluster node by setting this node up as a cluster + node, but also starts the Failover Cluster and enables S2D. This will + be run twice: First before a restart of the VM where we remove SQL and + join the domain, then a second time where we set up SQL FCI. + .PARAMETER domain_joined_flag + File path that, if exists, signifies that the domain should already + have been joined. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $domain_joined_flag + ) + if (Test-Path $domain_joined_flag) { + # Based on the domain_joined_flag, this is the second time this + # function has been run and so we should now be joined to a domain. + if (Check-JoinedDomain) { + WaitFor-RuntimeReady -url $script:create_cluster_url -timeout 1600 + Start-NewCluster + WaitFor-Cluster + Start-Sleep 30 + Enable-S2D + try { + Setup-FCI -config_contents $script:sql_ini_master + if ($script:is_test -eq 'true') { + Setup-MasterTest + } + 'Setup Complete!!!' | Set-Content $script:complete_flag + Mark-RuntimeDone -url $script:create_cluster_url ` + -result 'success' + } catch { + Write-Host $_ + Write-Host ('Failed to set up FCI') + Mark-RuntimeDone -url $script:create_cluster_url ` + -result 'failure' + } + } + } + else { + # We have not yet joined the domain so this is the first time this + # function is run. First prepare the instance, then restart the VM. + Prepare-Instance -flag $domain_joined_flag + } +} + + +function Setup-ClusterNode { + <# + .SYNOPSIS + Overall set up for a cluster node (non-master node) + .DESCRIPTION + Executes steps required on the cluster node. This function will be + run twice: First before a restart of the VM where we remove SQL and + join the domain, then a second time where we set up SQL FCI. + .PARAMETER domain_joined_flag + File path that, if exists, signifies that the domain should already + have been joined. + #> + param ( + [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] + $domain_joined_flag + ) + if (Test-Path $domain_joined_flag) { + # Based on the domain_joined_flag, this is the second time this + # function has been run and so we should now be joined to a domain. + if (Check-JoinedDomain) { + WaitFor-RuntimeReady -url $script:install_fci_url -timeout 1600 + try { + Setup-FCI -config_contents $script:sql_ini + Mark-RuntimeDone -url $script:install_fci_url ` + -result 'success' + 'Setup Complete!!!' | Set-Content $script:complete_flag + } catch { + Write-Host $_ + Write-Host ('Failed to set up FCI') + Mark-RuntimeDone -url $script:install_fci_url ` + -result 'failure' + } + } + } + else { + # We have not yet joined the domain so this is the first time this + # function is run. First prepare the instance, then restart the VM. + Prepare-Instance -flag $domain_joined_flag + } +} + + +function Check-SetupNeeded { + <# + .SYNOPSIS + Simple check to verify that the setup process has previously + completed. + #> + return !(Test-Path $script:complete_flag) +} + +function WaitFor-S2D { + <# + .SYNOPSIS + Blocks until the S2D virtual disks are in health state. + .DESCRIPTION + This code blocks until the S2D virtual disks have successfully reached + "healthy" state, performing a "repair-virtualdisk" if needed. + This is helpful in testing because if we take down a node as a part of + the test, this gives us a way to ensure that the node is back to a state + where further power-down operations can safely take place. + + If we suddenly power down a node that is part of an S2D cluster, the + virtual disk will be in a degraded state, which may cause the entire + S2D deployment to fail if more nodes are powered down. + #> + $sw = [Diagnostics.Stopwatch]::StartNew() + WaitFor-Cluster + $repair_retry_time = (Get-Date) + $expire_time = (Get-Date) + (New-TimeSpan -Seconds 700) + while ((Get-VirtualDisk).HealthStatus -ne 'Healthy') { + if ((Get-Date) -gt $expire_time) { + Write-Error 'S2D Failed to come up' + } + if ((Get-Date) -gt $repair_retry_time) { + Repair-VirtualDisk -FriendlyName VDisk01 -Verbose -Confirm:$false -AsJob + # try again in 2 minutes if it has not succeeded by then. Retrying the + # operation has a tendency to "just work" sometimes. + $repair_retry_time = (Get-Date) + (New-TimeSpan -Seconds 120) + } + Start-Sleep 1 + } + + $sw.Stop() + $seconds_elapsed = $sw.Elapsed.TotalSeconds + Write-Host "S2D UP. Took $seconds_elapsed seconds." +} + +# Main. 3 different code paths (with some overlap) for the 3 different node roles. +# AD node: AD Domain controller +# Master: The first cluster node. This node will be used to install the Fail over cluster +# and enable S2D +# Cluster: All other cluster nodes. +if ($script:is_ad_node -eq 'true') { + Write-Host('Running as AD Domain controller node.') + if (Check-SetupNeeded) { + Setup-AdNode + } +} +elseif ($script:is_master -eq 'true') { + Write-Host('Running as master node.') + if (Check-SetupNeeded) { + Setup-MasterNode -domain_joined_flag $script:joined_flag + } + else { + # Coming up from a reboot. Make sure S2D is up. + WaitFor-S2D + } +} +else { + Write-Host('Running as cluster node.') + if (Check-SetupNeeded) { + Setup-ClusterNode -domain_joined_flag $script:joined_flag + } + else { + # Coming up from a reboot. Make sure S2D is up. + WaitFor-S2D + } +} + diff --git a/community/sql_fci/internal_lb.py b/community/sql_fci/internal_lb.py new file mode 100644 index 000000000..ca1d00b4e --- /dev/null +++ b/community/sql_fci/internal_lb.py @@ -0,0 +1,163 @@ +# Copyright 2017 Google Inc. 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. +"""Top level google cloud deployment definition. + +This file is not meant to be used in general, and instead is written to +interract with google's cloud deployment manager system. The key function in +this file is "GenerateConfig", which the deployment manager will call to get +the list of resources it must deploy. + +Without the context of google's deployment manager system, this file will +probably not make any sense. Please see: + +https://cloud.google.com/deployment-manager/docs/ + +for the necessary background. +""" + +import common +import default +import utils + + +BACKEND_SERVICE_NAME = 'cluster-backend-service' +FORWARD_RULE_NAME = 'lb-forward-rule' + + +def _BackendServiceName(deployment): + return '{deployment}-{name}'.format( + deployment=deployment, name=BACKEND_SERVICE_NAME) + + +def _ForwardRuleName(deployment): + return '{deployment}-{name}'.format( + deployment=deployment, name=FORWARD_RULE_NAME) + + +def HealthCheck(deployment, health_check_port, application_ip): + """Returns a definition of a health check. + + Args: + deployment: the name of this deployment. + health_check_port: the port to perform the health check on. + application_ip: the ip to perform the health check on. + + Returns: + A definition of a health check. + """ + return { + default.NAME: utils.HealthCheckName(deployment), + default.TYPE: 'compute.v1.healthCheck', + default.PROPERTIES: { + default.TYPE: 'TCP', + default.TCP_HEALTH_CHECK: { + default.PORT: health_check_port, + 'request': application_ip, + 'response': 1 + } + } + } + + +def BackendService(region, deployment, net_name, num_cluster_nodes): + """Returns a definition of a backend service. + + Args: + region: The region that this backend service will be deployed to. + deployment: The name of this deployment. + net_name: The name of the network that this backend service will operate on. + num_cluster_nodes: number of cluster nodes in the deployment + + Returns: + A definition of a backend service. + """ + backends = [] + for zone in utils.GetZoneSet(region, num_cluster_nodes): + backends.append({ + 'group': common.Ref(utils.InstanceGroupName(deployment, zone)) + }) + return { + default.NAME: _BackendServiceName(deployment), + default.TYPE: default.REGION_BACKEND_SERVICE, + default.PROPERTIES: { + default.REGION: + region, + default.NETWORK: + common.Ref(net_name), + default.BACKENDS: + backends, + default.HEALTH_CHECKS: [ + common.Ref(utils.HealthCheckName(deployment)) + ], + default.PROTOCOL: + 'TCP', + default.LB_SCHEME: + 'INTERNAL' + } + } + + +def ForwardingRule(deployment, region, port, cidr, net_name, sub_name): + """Returns a definition of a forwarding rule. + + Args: + deployment: The name of this deployment. + region: The region that this backend service will be deployed to. + port: The port of the traffic to forward. + cidr: string representing the cidr in a.b.c.d/x form. + net_name: The name of the network that this backend service will operate on. + sub_name: The name of the subnet that this backend service will operate on. + + Returns: + The definition of the forwarding rule. + """ + return { + default.NAME: _ForwardRuleName(deployment), + default.TYPE: default.FORWARDING_RULE, + default.PROPERTIES: { + default.PORTS: [port], + default.IP_ADDRESS: utils.ApplicationIp(cidr), + default.NETWORK: common.Ref(net_name), + default.SUBNETWORK: common.Ref(sub_name), + default.REGION: region, + 'backendService': common.Ref(_BackendServiceName(deployment)), + default.LB_SCHEME: 'INTERNAL' + } + } + + +def GenerateConfig(context): + """Generates resources based on properties. + + Args: + context: the context of this deployment. + + Returns: + list of resources required to set up the load balancer service. + """ + deployment = context.env['deployment'] + region = context.properties['region'] + net_name = utils.NetworkName(deployment) + sub_name = utils.SubnetName(deployment) + sql_cidr = context.properties.get('sql_cidr', utils.DEFAULT_DEPLOYMENT_CIDR) + application_ip = utils.ApplicationIp(sql_cidr) + num_cluster_nodes = context.properties['num_cluster_nodes'] + return { + 'resources': [ + HealthCheck(deployment, utils.HEALTH_CHECK_PORT, application_ip), + BackendService(region, deployment, net_name, num_cluster_nodes), + ForwardingRule(deployment, region, utils.APPLICATION_PORT, sql_cidr, + net_name, sub_name) + ] + } diff --git a/community/sql_fci/password.py b/community/sql_fci/password.py new file mode 100644 index 000000000..273210a63 --- /dev/null +++ b/community/sql_fci/password.py @@ -0,0 +1,135 @@ +# Copyright 2015 Google Inc. 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. +"""A DM template that generates password as an output, namely "password". + +An example YAML showing how this template can be used: + resources: + - name: generated-password + type: password.py + properties: + length: 8 + includeSymbols: true + - name: main-template + type: main-template.jinja + properties: + password: $(ref.generated-password.password) + +Input properties to this template: + - length: the length of the generated password. At least 8. Default 8. + - includeSymbols: true/false whether to include symbol chars. Default false. + +The generated password satisfies the following requirements: + - The length is as specified, + - Containing letters and numbers, and optionally symbols if specified, + - Starting with a letter, + - Containing characters from at least 3 of the 4 categories: uppercases, + lowercases, numbers, and symbols. + +""" + +import random +import yaml + +PROPERTY_LENGTH = 'length' +PROPERTY_INCLUDE_SYMBOLS = 'includeSymbols' + +# Note the omission of some hard to distinguish characters like I, l, 0, and O. +UPPERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ' +LOWERS = 'abcdefghijkmnopqrstuvwxyz' +ALPHABET = UPPERS + LOWERS +DIGITS = '123456789' +ALPHANUMS = ALPHABET + DIGITS +# Including only symbols that can be passed around easily in shell scripts. +SYMBOLS = '*-+.' + +CANDIDATES_WITH_SYMBOLS = ALPHANUMS + SYMBOLS +CANDIDATES_WITHOUT_SYMBOLS = ALPHANUMS + +CATEGORIES_WITH_SYMBOLS = [UPPERS, LOWERS, DIGITS, SYMBOLS] +CATEGORIES_WITHOUT_SYMBOLS = [UPPERS, LOWERS, DIGITS] + +MIN_LENGTH = 8 + + +class InputError(Exception): + """Raised when input properties are unexpected.""" + + +def GenerateConfig(context): + """Entry function to generate the DM config.""" + props = context.properties + length = props.setdefault(PROPERTY_LENGTH, MIN_LENGTH) + include_symbols = props.setdefault(PROPERTY_INCLUDE_SYMBOLS, False) + + if not isinstance(include_symbols, bool): + raise InputError('%s must be a boolean' % PROPERTY_INCLUDE_SYMBOLS) + + content = { + 'resources': [], + 'outputs': [{ + 'name': 'password', + 'value': GeneratePassword(length, include_symbols) + }] + } + return yaml.dump(content) + + +def GeneratePassword(length=8, include_symbols=False): + """Generates a random password.""" + if length < MIN_LENGTH: + raise InputError('Password length must be at least %d' % MIN_LENGTH) + + candidates = (CANDIDATES_WITH_SYMBOLS if include_symbols + else CANDIDATES_WITHOUT_SYMBOLS) + categories = (CATEGORIES_WITH_SYMBOLS if include_symbols + else CATEGORIES_WITHOUT_SYMBOLS) + + # Generates up to the specified length minus the number of categories. + # Then inserts one character for each category, ensuring that the character + # satisfy the category if the generated string hasn't already. + generated = ([random.choice(ALPHABET)] + + [random.choice(candidates) + for _ in range(length - 1 - len(categories))]) + for category in categories: + _InsertAndEnsureSatisfaction(generated, category, candidates) + return ''.join(generated) + + +def _InsertAndEnsureSatisfaction(generated, required, all_candidates): + """Inserts 1 char into generated, satisfying required if not already. + + If the required characters are not already in the generated string, one will + be inserted. If any required character is already in the generated string, a + random character from all_candidates will be inserted. The insertion happens + at a random location but not at the beginning. + + Args: + generated: the string to be modified. + required: list of required characters to check for. + all_candidates: list of characters to choose from if the required characters + are already satisfied. + """ + if set(generated).isdisjoint(required): + # Not yet satisfied. Insert a required candidate. + _InsertInto(generated, required) + else: + # Already satisfied. Insert any candidate. + _InsertInto(generated, all_candidates) + + +def _InsertInto(generated, candidates): + """Inserts a random candidate into a random non-zero index of generated.""" + # Avoids inserting at index 0, since the first character follows its own rule. + generated.insert(random.randint(1, len(generated) - 1), + random.choice(candidates)) diff --git a/community/sql_fci/sql_fci_deployment.py b/community/sql_fci/sql_fci_deployment.py new file mode 100644 index 000000000..4e03b8366 --- /dev/null +++ b/community/sql_fci/sql_fci_deployment.py @@ -0,0 +1,57 @@ +# Copyright 2017 Google Inc. 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. +"""Top level google cloud deployment definition. + +This file is not meant to be used in general, and instead is written to +interract with google's cloud deployment manager system. The key function in +this file is "GenerateConfig", which the deployment manager will call to get +the list of resources it must deploy. + +Without the context of google's deployment manager system, this file will +probably not make any sense. Please see: + +https://cloud.google.com/deployment-manager/docs/ + +for the necessary background. +""" + + +def GenerateConfig(context): + """Returns a list of resources that represent the entire deployment. + + Args: + context: context of the deployment. + + Returns: + List of resources that the deployment will create. + """ + resources = [{ + 'name': 'sql_network', + 'type': 'sql_network.py', + 'properties': context.properties + }, { + 'name': 'vms', + 'type': 'vms.py', + 'properties': context.properties + }, { + 'name': 'internal_lb', + 'type': 'internal_lb.py', + 'properties': context.properties + }, { + 'name': 'checkpoints', + 'type': 'checkpoints.py', + 'properties': context.properties + }] + + return {'resources': resources} diff --git a/community/sql_fci/sql_fci_deployment.py.display b/community/sql_fci/sql_fci_deployment.py.display new file mode 100644 index 000000000..ba677ead0 --- /dev/null +++ b/community/sql_fci/sql_fci_deployment.py.display @@ -0,0 +1,55 @@ +description: + author: + title: Google Click to Deploy + descriptionHtml: Popular open stacks on Google Compute Engine packaged by Google. + shortDescription: Popular open stacks on Google Compute Engine packaged by Google. + url: 'https://cloud.google.com/solutions/#click-to-deploy' + title: SQL Server 2016 AlwaysOn Failover Cluster Instance (Beta) + tagline: SQL Server 2016 AlwaysOn Failover Cluster Instance (Beta) + architectureDescription: 'Note: This deployment will create multiple instances with SSD Persistent Disks attached. You may need to increase your resource quotas to successfully deploy. Please visit Resource Quotas in the Google Documentation for information on increasing resource quotas.' + version: Microsoft SQL FCI + softwareGroups: + - software: + - title: Microsoft SQL Server + +input: + properties: + - name: region + title: Region + section: DEPLOYMENT + - name: domain + title: Active Directory Domain Name + section: DEPLOYMENT + - name: domain_netbios + title: Active Directory Domain NetBios Name + section: DEPLOYMENT + - name: service_account + title: Service Account Name + section: DEPLOYMENT + - name: num_cluster_nodes + title: Number of Cluster Nodes + section: DEPLOYMENT + - name: machine_type + title: Machine Type + section: VM + - name: volume_size_gb + title: SQL Volume Size + tooltip: The volume size, and the disk configuration required to support it. Note that S2D has a small overhead per disk, and this is compensated for by deploying more disk space than is used by the volume. + section: DEPLOYMENT + sections: + - name: VM + title: VM Details + tooltip: Information regarding the makeup of your cluster nodes. + - name: DEPLOYMENT + title: Overall Deployment Details + +runtime: + deployingMessage: Deployment can take up to 40 minutes to complete + applicationTable: + rows: + - label: Internal Load Balancer IP + value: 10.129.0.201 + - label: Number of PD SSDs per VM + value: 4 + +metadata_version: v1 diff --git a/community/sql_fci/sql_fci_deployment.py.schema b/community/sql_fci/sql_fci_deployment.py.schema new file mode 100644 index 000000000..487e78bcf --- /dev/null +++ b/community/sql_fci/sql_fci_deployment.py.schema @@ -0,0 +1,65 @@ +imports: +- path: checkpoints.py +- path: common.py +- path: default.py +- path: internal_lb.py +- path: install.ps1 +- path: password.py +- path: sql_fci_deployment.py +- path: sql_network.py +- path: utils.py +- path: vms.py + +info: + version: 1.0 + title: Microsoft SQL FCI Cluster Template + +required: + - region + - domain + - domain_netbios + - service_account + - num_cluster_nodes + - machine_type + + +properties: + region: + description: Region where the cluster nodes will reside. + type: string + default: us-west1 + x-googleProperty: + type: GCE_REGION + num_cluster_nodes: + description: The number of cluster nodes. + type: integer + default: 2 + enum: + - 2 + - 3 + machine_type: + description: The type of the cluster node VM. + type: string + default: "4 vCPUs, 15 GB Memory" + enum: + - "4 vCPUs, 15 GB Memory" + - "8 vCPUs, 30 GB Memory" + domain: + description: URL used by the Active Directory service. + type: string + default: testdomain.com + domain_netbios: + description: The Active Directory group name. + type: string + default: testdomain + service_account: + description: The name of the Active Directory service account. + type: string + default: wsfc_service + volume_size_gb: + description: Size of the SQL data volume, in GB. + type: string + default: "500 GB (128 GB PD SSD x 4)" + enum: + - "500 GB (128 GB PD SSD x 4)" + - "1000 GB (256 GB PD SSD x 4)" \ No newline at end of file diff --git a/community/sql_fci/sql_network.py b/community/sql_fci/sql_network.py new file mode 100644 index 000000000..b6aae6044 --- /dev/null +++ b/community/sql_fci/sql_network.py @@ -0,0 +1,159 @@ +# Copyright 2017 Google Inc. 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. +"""Generates a config defining the network in the deployment. + +This file is not meant to be used in general, and instead is written to +interract with google's cloud deployment manager system. The key function in +this file is "GenerateConfig", which the deployment manager will call to get +the list of resources it must deploy. + +Without the context of google's deployment manager system, this file will +probably not make any sense. Please see: + +https://cloud.google.com/deployment-manager/docs/ + +for the necessary background. +""" + +import common +import default +import utils + + +def FirewallRule(name, net_name, protocol, deployment, sources, ports=None): + """Creates a Firewall Rule definition. + + Returns a firewall definition based on arguments that is compatible with + the gcloud. + + Args: + name: string name of the firewall rule + net_name: string name of the network that this rule will apply to. + protocol: The network protocol, e.g. 'ICMP', 'TCP', 'UDP' + deployment: name of this deployment. + sources: list of strings cidrs of traffic to be allowed. + ports: the TCP or UDP ports that this firewall rule will apply to. + + Returns: + Firewall Rule definition compatible with gcloud deployment launcher. + """ + allowed = { + default.IP_PROTO: protocol + } + + if ports: + allowed.update({default.PORTS: [ports]}) + + properties = { + default.NETWORK: common.Ref(net_name).format(net_name), + default.ALLOWED: [allowed], + default.SRC_RANGES: sources + } + + firewall_rule_name = "{deployment}-{name}".format( + deployment=deployment, + name=name) + + return { + default.NAME: firewall_rule_name, + default.TYPE: default.FIREWALL, + default.PROPERTIES: properties + } + + +def GenerateConfig(context): + """Generates the network configuration for the gcloud deployment. + + Args: + context: context of the deployment. + + Returns: + List of resources that the deployment manager will create. + """ + + region = context.properties["region"] + sql_cidr = context.properties.get("sql_cidr", utils.DEFAULT_DEPLOYMENT_CIDR) + deployment = context.env["deployment"] + net_name = utils.NetworkName(deployment) + sub_name = utils.SubnetName(deployment) + is_test = context.properties.get("dev_mode", "false") + + resources = [ + { + default.NAME: net_name, + default.TYPE: default.NETWORK_TYPE, + default.PROPERTIES: { + default.AUTO_CREATE_SUBNETWORKS: False, + } + }, + { + default.NAME: sub_name, + default.TYPE: default.SUBNETWORK_TYPE, + default.PROPERTIES: { + default.NETWORK: common.Ref(net_name), + default.REGION: region, + default.IP_CIDR_RANGE: sql_cidr + } + }, + # Allow ICMP for debugging + FirewallRule( + "allow-all-icmp", net_name, "ICMP", deployment, sources=[sql_cidr]), + + # Allow RDP, SQL, and Load Balancer Health Check from anywhere + FirewallRule( + "allow-rdp-port", + net_name, + "TCP", + deployment, + sources=["0.0.0.0/0"], + ports="3389"), + FirewallRule( + "allow-health-check-port", + net_name, + "TCP", + deployment, + # The Google ILB health check service IP ranges. + sources=["130.211.0.0/22", "35.191.0.0/16"], + ports=utils.HEALTH_CHECK_PORT), + + # Allow ALL TCP and UDP traffic from within the same network. We should + # only have cluster and AD nodes on this network so the traffic is + # trusted. + FirewallRule( + "allow-all-udp", + net_name, + "UDP", + deployment, + sources=[sql_cidr], + ports="0-65535"), + FirewallRule( + "allow-all-tcp", + net_name, + "TCP", + deployment, + sources=[sql_cidr], + ports="0-65535"), + ] + + if is_test: + resources.append( + FirewallRule( + "allow-sql-port", + net_name, + "TCP", + deployment, + sources=["0.0.0.0/0"], + ports=utils.APPLICATION_PORT)) + + return {"resources": resources} diff --git a/community/sql_fci/test_config.yaml b/community/sql_fci/test_config.yaml new file mode 100644 index 000000000..5f5644a4c --- /dev/null +++ b/community/sql_fci/test_config.yaml @@ -0,0 +1,40 @@ +# Copyright 2017 Google Inc. 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. + +imports: +- path: checkpoints.py +- path: common.py +- path: checkpoints.py +- path: default.py +- path: internal_lb.py +- path: install.ps1 +- path: password.py +- path: sql_fci_deployment.py +- path: install.ps1 +- path: internal_lb.py +- path: sql_network.py +- path: utils.py +- path: vms.py + +resources: +- name: sql_fci_deployment + type: sql_fci_deployment.py + properties: + domain: testdomain.com + domain_netbios: testdomain + service_account: wsfc_service + region: us-west1 + machine_type: "4 vCPUs, 15 GB Memory" + num_cluster_nodes: 2 + volume_size_gb: "1000 GB (256 GB PD SSD x 4)" diff --git a/community/sql_fci/utils.py b/community/sql_fci/utils.py new file mode 100644 index 000000000..6472cc7e6 --- /dev/null +++ b/community/sql_fci/utils.py @@ -0,0 +1,498 @@ +# Copyright 2017 Google Inc. 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. +"""Helper functions used throughout the template files. + +Some things need to stay consistent across the template files, mostly +names of resources. These need to be consistent because they are used to +generate references. + +the python module "ipaddress" already handles what these functions do, but +third-party python modules aren't allowed to be imported: + +https://cloud.google.com/deployment-manager/docs/configuration/templates/import-python-libraries + +""" + +# CIDR used by the cluster nodes and the load balancer. +DEFAULT_DEPLOYMENT_CIDR = "10.129.0.0/24" + +# extra constants defining fields required to define a scratch disk +DISK_INTERFACE = "interface" +DISK_MODE = "mode" +SSD_DISK_INTERFACE = "SCSI" +SSD_DISK_MODE = "READ_WRITE" +SSD_DISK_TYPE = "SCRATCH" + +# These could be potentially configurable by the user in the future, however +# right now these are the only values tested, and making the ports not +# configurable keeps clutter off of the cloud launcher. +HEALTH_CHECK_PORT = 1818 +APPLICATION_PORT = 1433 + +# somewhat arbitrary. We just want a sensible maximum number of IPs to allocate +# on the subnet, and it can't overlap with the reserved IP numbers below or +# go over the maximum amount of IPs in a byte. +_MAX_IP_NODE_NUMBER = 198 +_MIN_IP_NUMBER = 2 + +# we need to offset the IPs by 2 to leave one IP for the subnet (a.b.c.0) and +# one IP for the gateway (a.b.c.1) +_IP_NODE_NUMBER_OFFSET = 2 + +# Maximum name length for VMs. Active directory has a requirement that the name +# lengths be less than 15 characters. +_MAX_NAME_LEN = 14 + + +# Ip address numbers for specific purposes. This assumes that there will be +# less than 199 nodes in the cluster. +_AD_NODE_IP_SUFFIX = _MAX_IP_NODE_NUMBER + 1 +_CLUSTER_IP_SUFFIX = _AD_NODE_IP_SUFFIX + 1 +_APPLICATION_IP_SUFFIX = _CLUSTER_IP_SUFFIX + 1 +_MAX_IP_NUMBER = _APPLICATION_IP_SUFFIX + + +_RTC_ENDPOINT = "https://runtimeconfig.googleapis.com/v1beta1" + +AD_NODE_NAME = "ad" + +_NET_NAME = "net" +_SUB_NAME = "sub" + +# These endpoints make unique runtimeconfig URLs, config names, and waiter +# names unique. Each of these endpoints should have a 1:1 correspondence with a +# "phase" in the deployment. +CREATE_DOMAIN_URL_ENDPOINT = "create_domain" +JOIN_DOMAIN_URL_ENDPOINT = "join_domain" +CREATE_CLUSTER_URL_ENDPOINT = "cluster" +INSTALL_FCI_URL_ENDPOINT = "fci" + +# Microsoft AD node names are not allowed to have the following characters +_INVALID_NAME_CHARS = set(":*?\"<>|\\/") + +_REGION_TO_ZONES = { + "northamerica-northeast1": ["a", "b", "c"], + "southamerica-east1": ["a", "b", "c"], + "australia-southeast1": ["a", "b", "c"], + "asia-south1": ["a", "b", "c"], + "asia-northeast1": ["a", "b", "c"], + "asia-southeast1": ["a", "b"], + "asia-east1": ["a", "b", "c"], + "europe-west2": ["a", "b", "c"], + "europe-west3": ["a", "b", "c"], + "europe-west1": ["d", "b", "c"], + "europe-west4": ["b", "c"], + "us-west1": ["a", "b", "c"], + "us-central1": ["a", "b", "c", "f"], + "us-east1": ["d", "b", "c"], + "us-east4": ["a", "b", "c"], +} + + +class IPv4CidrValidationError(Exception): + """Raised when a CIDR is not valid.""" + + +class IPv4OutOfRangeError(Exception): + """Raised when an IP is out of the accepted range.""" + + +class VmInputValidationError(Exception): + """Raised when some VM config is invalid.""" + + +def _GetZoneFromRegion(region, zone_number): + return "{region}-{zone}".format( + region=region, zone=_REGION_TO_ZONES[region][zone_number]) + + +def ConvertVolumeSizeString(volume_size_gb): + """Converts the volume size defined in the schema to an int.""" + volume_sizes = { + "500 GB (128 GB PD SSD x 4)": 500, + "1000 GB (256 GB PD SSD x 4)": 1000, + } + return volume_sizes[volume_size_gb] + + +def ConvertMachineTypeString(machine_type): + """Converts a machine type defined in the schema to a GCE compatible form.""" + machine_types = { + "4 vCPUs, 15 GB Memory": "n1-standard-4", + "8 vCPUs, 30 GB Memory": "n1-standard-8" + } + return machine_types[machine_type] + + +def GetDefaultZoneFromRegion(region): + return _GetZoneFromRegion(region, 0) + + +def GetNodeZoneFromRegion(region, node_num): + zones = _REGION_TO_ZONES[region] + zone_index = node_num % len(zones) + return _GetZoneFromRegion(region, zone_index) + + +def GetZoneSet(region, number_of_nodes): + """Returns the set of zones that will be used with this deployment. + + Args: + region: The region being used for this deployment + number_of_nodes: The number of cluster nodes being used in this deployment + + + Returns: + set of zones that will be used in this deployment. + """ + zones = set() + for node_index in xrange(number_of_nodes): + zones.add(GetNodeZoneFromRegion(region, node_index)) + return zones + + +def NetworkName(deployment): + """Returns the name of the network based on the deployment. + + Args: + deployment: the name of this deployment. + + Returns: + The name of the network. + """ + return "{}-{}".format(deployment, _NET_NAME) + + +def SubnetName(deployment): + """Returns the name of the subnet based on the deployment. + + Args: + deployment: the name of this deployment. + + Returns: + The name of the subnet. + """ + return "{}-{}".format(deployment, _SUB_NAME) + + +def ValidateNodeName(name): + """Validates node name. + + Because all nodes will be a part of the Active Directory, the node names have + to conform to the standards here: + + https://technet.microsoft.com/en-us/library/cc961556.aspx + + Args: + name: the name of the node. + + Raises: + VmInputValidationError: if there is a problem with the input + """ + + invalid_chars = _INVALID_NAME_CHARS.intersection(name) + if invalid_chars: + raise VmInputValidationError("Node name must not contain any of {invalid}. " + "Please check the deployment name and " + "ensure that it does not contain the {invalid}" + " character".format(invalid=invalid_chars)) + if len(name) > _MAX_NAME_LEN: + raise VmInputValidationError( + "Node name is too long. Node names are based on deployment name" + " and total length must be no longer than {} characters.".format( + _MAX_NAME_LEN)) + + +def NodeName(deployment, suffix): + """Returns the name of the node based on the node number. + + Args: + deployment: the name of this deployment. + suffix: the number of this node. + + Raises: + VmInputValidationError: if there is a problem with the input + + Returns: + The name of the node. + """ + node_suffix = "-{}".format(suffix) + deployment = deployment[0:_MAX_NAME_LEN - len(node_suffix)] + node_name = "{}{}".format(deployment, node_suffix) + + ValidateNodeName(node_name) + return node_name + + +def AdNodeName(deployment): + """Returns the name of the active directory node. + + Args: + deployment: the name of this deployment. + + Returns: + The name of the active directory node. + """ + return NodeName(deployment, AD_NODE_NAME) + + +def AllNodeNames(deployment, num_vms): + """Returns a list of all cluster node names, as a string. + + Args: + deployment: the name of this deployment. + num_vms: total number of cluster vms in the system. + + Returns: + A list of all cluster node names, as a string. For example: + "node-0,node-1,node-2" + """ + + def Name(node_number): + return NodeName(deployment, node_number) + + return ",".join(Name(node_number) for node_number in xrange(num_vms)) + + +def ConfigName(deployment, name): + """Returns the name of the config. + + Args: + deployment: the name of the deployment. + name: the "tag" used to differentiate this config from others. + + Returns: + The name of the config. + """ + return "{}-config-{}".format(deployment, name) + + +def WaiterName(deployment, name): + """Returns the name of the waiter. + + Args: + deployment: the name of the deployment. + name: the "tag" used to differentiate this waiter from others. + + Returns: + The name of the waiter. + """ + return "{}-waiter-{}".format(deployment, name) + + +def ConfigURL(deployment, project, name): + """Returns the URL that this config will use. + + Args: + deployment: the name of the deployment. + project: the project used to deploy this config. + name: the "tag" used to differentiate this config from others. + + Returns: + The name of the waiter. + """ + return "{}/projects/{}/configs/{}".format( + _RTC_ENDPOINT, project, ConfigName(deployment, name)) + + +def ValidateIPv4Cidr(cidr): + """Raises an error if the string provided is not a valid cidr. + + There are two alternative approaches to having this function, both have + problems: + 1) just use the python "ipaddress" module, which has IP validation built in + already. The problem with that is that the deployment manager (the system + that will be executing this code) will not have access to that module. + 2) have an IPv4 CIDR regex, which are readily available through a Google + search. The problem with this approach is that the regex is hard to + understand and you can not fail with fine grained error messages. + + We also have slightly more strict requirements than just making sure the + cidr is valid: we want the subnet length to rest on a byte, and we want the + unused bytes in the cidr to be '0'. These extra requirements makes + IP manipulation/building easier. + + Args: + cidr: standard cidr, as a string. For example 10.0.2.0/24 + + Raises: + IPv4CidrValidationError: if the cidr string is not in standard cidr + notation. + ValueError: if the cidr does not have a subnet length. + """ + + # Cidrs need to be an IP, followed by a '/', followed by a subnet length + ip_with_subnet_length = cidr.split("/") + if len(ip_with_subnet_length) != 2: + raise ValueError("missing subnet length from cidr") + + # The subnet length must be an 8, 16, or 24 + subnet_length = ip_with_subnet_length[1] + if subnet_length != "8" and subnet_length != "16" and subnet_length != "24": + raise IPv4CidrValidationError("subnet length must be either 8, 16, or 24.") + + # The IP needs to have 4 segments + ip_segments = ip_with_subnet_length[0].split(".") + if len(ip_segments) != 4: + raise IPv4CidrValidationError("the ip in the cidr must have 4 segments " + "separated by a '.' character.") + + # The 4 IP segments need to be in base 10 and be between 0 and 255, inclusive. + for segment in ip_segments: + try: + segment_val = int(segment) + if segment_val < 0 or segment_val > 255: + raise IPv4CidrValidationError("ip segments must be between 0 and 255" + "(inclusive)") + + except ValueError: + raise IPv4CidrValidationError("ip segments must be base 10 integers.") + + # If the subnet length is 8, then the last 3 bytes of the IP must be 0 + if subnet_length == "8" and (ip_segments[1] != "0" or + ip_segments[2] != "0" or + ip_segments[3] != "0"): + raise IPv4CidrValidationError("if the subnet length is 8, then the " + "second, third and fourth bytes must be 0.") + + # If the subnet length is 16, then the last 2 bytes of the IP must be 0 + if subnet_length == "16" and (ip_segments[2] != "0" or + ip_segments[3] != "0"): + raise IPv4CidrValidationError("if the subnet length is 16, then the " + "third and fourth bytes must be 0.") + + # If the subnet length is 24, then the last byte of the IP must be 0 + if subnet_length == "24" and ip_segments[3] != "0": + raise IPv4CidrValidationError("if the subnet length is 24, then the " + "fourth byte must be 0.") + + +def GetIp(cidr, ip_number): + """Gets the IP in the cidr that corresponds to ip_number. + + Args: + cidr: string representing the cidr in a.b.c.d/x form. + ip_number: number of the IP. + + Raises: + IPv4OutOfRangeError: the ip_number is not within the acceptable range. + + Returns: + the IP address that corresponds to ip_number. For example, if cidr is + 10.0.0.0/24, and num is 1, returns 10.0.0.1 + """ + ValidateIPv4Cidr(cidr) + + if ip_number < _MIN_IP_NUMBER or ip_number > _MAX_IP_NUMBER: + raise IPv4OutOfRangeError("ip number needs to be between {} and {}".format( + _MIN_IP_NUMBER, _MAX_IP_NUMBER)) + + base = cidr.split("/")[0].split(".") + base[-1] = str(ip_number) + return ".".join(base) + + +def NodeIp(cidr, node_number): + """Returns the IP to be used by a node VM, as a string. + + We add 2 because 0 is reserved for the subnet address and 1 is reserved + for the gateway. so node-0 gets ip of x.y.z.2, node-1 gets x.y.z.3, etc + + Args: + cidr: string representing the cidr in a.b.c.d/x form. + node_number: number of the node VM. + + Raises: + IPv4OutOfRangeError: The node IP number is not within the valid range + + Returns: + The IP to be used by a node VM, as a string + """ + + ip_number = node_number + _IP_NODE_NUMBER_OFFSET + + if ip_number > _MAX_IP_NODE_NUMBER: + raise IPv4OutOfRangeError("ip number needs to be between {} and {}".format( + _MIN_IP_NUMBER, _MAX_IP_NODE_NUMBER)) + + return GetIp(cidr, ip_number) + + +def AdNodeIp(cidr): + """Returns the IP to be used by the AD VM, as a string. + + Args: + cidr: string representing the cidr in a.b.c.d/x form. + + Returns: + The IP to be used by the AD VM, as a string. + """ + return GetIp(cidr, _AD_NODE_IP_SUFFIX) + + +def ClusterIp(cidr): + """Returns the IP to be used by the backend cluster. + + Args: + cidr: string representing the cidr in a.b.c.d/x form. + + Returns: + The IP to be used by the backend cluster. + """ + return GetIp(cidr, _CLUSTER_IP_SUFFIX) + + +def ApplicationIp(cidr): + """Returns the IP to be used by the SQL server. + + Requests to SQL server from the clients will use this IP. + + Args: + cidr: string representing the cidr in a.b.c.d/x form. + + Returns: + The IP to be used by the SQL server. + """ + return GetIp(cidr, _APPLICATION_IP_SUFFIX) + + +def InstanceGroupName(deployment, zone): + """Returns the name of the instance group. + + A consistent name is required to define references and dependencies. + Assumes that only one instance group will be used for the entire deployment. + + Args: + deployment: the name of this deployment. + zone: the zone for this particular instance group + + Returns: + The name of the instance group. + """ + return "{}-instance-group-{}".format(deployment, zone) + + +def HealthCheckName(deployment): + """Returns the name of the health check. + + A consistent name is required to define references and dependencies. + Assumes that only one health check will be used for the entire deployment. + + Args: + deployment: the name of this deployment. + + Returns: + The name of the health check. + """ + return "{}-health-check".format(deployment) diff --git a/community/sql_fci/vms.py b/community/sql_fci/vms.py new file mode 100644 index 000000000..d04122f01 --- /dev/null +++ b/community/sql_fci/vms.py @@ -0,0 +1,765 @@ +# Copyright 2017 Google Inc. 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. +"""Generates a config defining the VMs in the gcloud deployment. + +This file is not meant to be used in general, and instead is written to +interract with google's cloud deployment manager system. The key function in +this file is "GenerateConfig", which the deployment manager will call to get +the list of resources it must deploy. + +Without the context of google's deployment manager system, this file will +probably not make any sense. Please see: + +https://cloud.google.com/deployment-manager/docs/ + +for the necessary background. +""" + +import common +import default +import utils + + +# number of cluster node requirements here: +# https://docs.microsoft.com/en-us/windows-server/storage/storage-spaces/storage-spaces-direct-hardware-requirements +_MIN_CLUSTER_NODES = 2 +_MAX_CLUSTER_NODES = 3 +_MIN_DISKS = 4 +# Nodes need their hostname to be less than 15 or else AD will not be +# able to distinguish between them +_MAX_NAME_LEN = 14 + +_PD_DISK = "pd-standard" +_PD_SSD_DISK = "pd-ssd" +_SSD_DISK = "local-ssd" + +_NUM_PD_SSD_DISKS = 4 + +# There is about a 2GB overhead per disk for S2D deployments +_S2D_OVERHEAD = 2 + +# At this point, we don't want the user to be able to configue any number. +# This keeps the testing area down. +_VALID_VOLUME_SIZES = (500, 1000) + +# Assumes 4 disks. Take the closest power of two that is larger than the volume. +_VOLUME_TO_DISK_SIZE = { + 500: 128, + 1000: 256 +} + +# Indicates that the VMs associated with this template are SQL FCI VMs. Used +# only for telemetry purposes. +_SQL_FCI_TAG = "SQLFCI" + +# The current set of supported images +_SQL_SVR_2016 = "sql-fci-2016-v20180213" +_WINDOWS_2016 = "sql-fci-ad-2016-v20180213" + +_SQL_FCI_PUBLIC_FAMILY = "sql-fci-public" + + +def DiskName(vm_name, disk_type, disk_number): + """Returns the name of this disk. + + Args: + vm_name: The name of the vm this disk will be attached to. + disk_type: The type of the disk. + disk_number: The number of this disk. Must be unique on the VM. + + Returns: + The name of the disk. + """ + return "{}-disk-{}-{}".format(vm_name, disk_type, disk_number) + + +def DiskType(project, zone, disk_type_str): + """Returns the full path of the disk type. + + Args: + project: the project of this deployment. + zone: The zone the disk will be deployed in. + disk_type_str: The name of the disk type. + + Returns: + The full path of the disk type. + """ + return "{}projects/{}/zones/{}/diskTypes/{}".format( + default.COMPUTE_URL_BASE, project, zone, disk_type_str) + + +def Disk(project, zone, vm_name, disk_type, disk_num, size_gb): + """Generates a top level disk. + + resource that gets created at the top level, as opposed + to a resource that is embedded within a VM definition. + + Args: + project: The owner project that is deploying this template. + zone: The zone that this disk will be deployed in. + vm_name: the name of the VM that will use this disk. + disk_type: the type of disk (PD, SSD, PD-SSD) + disk_num: the number of the disk, making it unique to the VM. + + size_gb: the size of the disk in GB. + Returns: + A top level disk resource definition. + """ + return { + default.NAME: DiskName(vm_name, disk_type, disk_num), + default.TYPE: default.DISK, + default.PROPERTIES: { + default.ZONE: zone, + default.SIZE_GB: size_gb, + default.TYPE: DiskType(project, zone, disk_type) + } + } + + +def SSDDisk(vm_name, disk_num, project, zone): + """Returns an SSD disk configuration. + + The definition is not top-level, and should be embedded within a disk + definition. + + Args: + vm_name: name of the VM that this disk will be on. + disk_num: the number for this disk. Must correspond to the top-level disk. + project: the project that is deploying the VM. + zone: the zone where this disk will be deployed. + + Returns: + A vm SSD disk configuration. + """ + disk_name = DiskName(vm_name, _SSD_DISK, disk_num) + return { + default.DEVICE_NAME: disk_name, + default.TYPE: utils.SSD_DISK_TYPE, + utils.DISK_INTERFACE: utils.SSD_DISK_INTERFACE, + utils.DISK_MODE: utils.SSD_DISK_MODE, + default.AUTO_DELETE: True, + default.INITIALIZEP: { + default.DISKTYPE: DiskType(project, zone, _SSD_DISK), + } + } + + +def VmDisk(vm_name, disk_type, disk_num): + """Returns a vm disk definition. + + The definition is not top-level, and should be embedded within a disk + definition. + + Args: + vm_name: name of the VM that this disk will be on. + disk_type: type of the disk. + disk_num: the number for this disk. Must correspond to the top-level disk. + + Returns: + A vm disk definition. + """ + disk_name = DiskName(vm_name, disk_type, disk_num) + return { + default.DEVICE_NAME: disk_name, + default.DISK_SOURCE: common.Ref(disk_name), + default.AUTO_DELETE: True + } + + +def BootDisk(base_name, device_name, project, zone, image): + """Returns a boot disk definition. + + This disk is necessary for all VMs, and will be less configurable by the user. + + Args: + base_name: name that the VM is based off of. + device_name: name of this disk. + project: the project that is deploying the VM. + zone: the zone where this disk will be deployed. + image: the full path to the image used for booting. + + Returns: + A boot disk definition. + """ + return { + default.DEVICE_NAME: device_name, + default.AUTO_DELETE: True, + default.BOOT: True, + default.INITIALIZEP: { + default.DISK_NAME: "{}-disk".format(base_name), + default.DISKTYPE: DiskType(project, zone, _PD_SSD_DISK), + default.SRCIMAGE: image, + default.DISK_SIZE: 100 + } + } + + +def BuildClusterInstanceMetadata(context, zone, node_number): + """Returns a list of key/value pairs for the cluster instance metadata. + + Args: + context: The context of the deployment. + zone: The zone where the VM will reside. + node_number: The unique number of this cluster VM. + + Returns: + A list of key value pairs specific to the cluster instance nodes. + """ + deployment = context.env["deployment"] + project = context.env["project"] + sql_cidr = context.properties.get("sql_cidr", utils.DEFAULT_DEPLOYMENT_CIDR) + service_account = context.properties["service_account"] + num_cluster_nodes = context.properties["num_cluster_nodes"] + is_test = context.properties.get("dev_mode", "false") + volume_size_gb = context.properties["volume_size_gb"] + + return { + "items": [ + { + "key": + "sysprep-specialize-script-ps1", + "value": ("Install-WindowsFeature -Name File-Services, " + "Failover-Clustering -IncludeManagementTools") + }, + { + "key": "enable-wsfc", + "value": "true" + }, + { + "key": "wsfc-agent-port", + "value": utils.HEALTH_CHECK_PORT + }, + { + "key": "windows-startup-script-ps1", + "value": context.imports["install.ps1"] + }, + { + "key": + "create-domain-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.CREATE_DOMAIN_URL_ENDPOINT) + }, + { + "key": + "join-domain-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.JOIN_DOMAIN_URL_ENDPOINT) + }, + { + "key": + "create-cluster-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.CREATE_CLUSTER_URL_ENDPOINT) + }, + { + "key": + "install-fci-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.INSTALL_FCI_URL_ENDPOINT) + }, + { + "key": "volume-size-gb", + "value": utils.ConvertVolumeSizeString(volume_size_gb) + }, + { + "key": "is-ad-node", + "value": "false" + }, + { + "key": "is-master", + "value": "true" if node_number == 0 else "false" + }, + { + "key": "ad-domain", + "value": context.properties["domain"] + }, + { + "key": "domain-netbios", + "value": context.properties["domain_netbios"] + }, + { + "key": "ad-node-ip", + "value": utils.AdNodeIp(sql_cidr) + }, + { + "key": "service-account", + "value": service_account + }, + { + "key": "service-password", + "value": "$(ref.service-password.password)" + }, + { + "key": "safe-password", + "value": "$(ref.safe-password.password)" + }, + { + "key": "my-node-name", + "value": utils.NodeName(deployment, node_number) + }, + { + "key": "ad-node-name", + "value": utils.AdNodeName(deployment) + }, + { + "key": "all-nodes", + "value": utils.AllNodeNames(deployment, num_cluster_nodes) + }, + { + "key": "cluster-ip", + "value": utils.ClusterIp(sql_cidr) + }, + { + "key": "application-ip", + "value": utils.ApplicationIp(sql_cidr) + }, + { + "key": "zone", + "value": zone + }, + { + "key": "instance-group", + "value": utils.InstanceGroupName(deployment, zone) + }, + { + "key": "is-test", + "value": is_test + }, + { + "key": "sql-fci-deployment-tag", + "value": _SQL_FCI_TAG + }, + ] + } + + +def BuildAdNodeInstanceMetadata(context, zone): + """Returns a list of key/value pairs for the AD node instance metadata. + + Args: + context: The context of the deployment. + zone: The zone where the VM will reside. + + Returns: + A list of key value pairs specific to the AD node. + """ + deployment = context.env["deployment"] + project = context.env["project"] + sql_cidr = context.properties.get("sql_cidr", utils.DEFAULT_DEPLOYMENT_CIDR) + service_account = context.properties["service_account"] + num_cluster_nodes = context.properties["num_cluster_nodes"] + volume_size_gb = context.properties["volume_size_gb"] + is_test = context.properties.get("dev_mode", "false") + + ad_node_name = utils.AdNodeName(deployment) + + return { + "items": [ + { + "key": "sysprep-specialize-script-ps1", + "value": "Add-WindowsFeature \"RSAT-AD-Tools\"" + }, + { + "key": "windows-startup-script-ps1", + "value": context.imports["install.ps1"] + }, + { + "key": "wsfc-agent-port", + "value": utils.HEALTH_CHECK_PORT + }, + { + "key": + "create-domain-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.CREATE_DOMAIN_URL_ENDPOINT) + }, + { + "key": + "join-domain-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.JOIN_DOMAIN_URL_ENDPOINT) + }, + { + "key": + "create-cluster-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.CREATE_CLUSTER_URL_ENDPOINT) + }, + { + "key": + "install-fci-config-url", + "value": + utils.ConfigURL(deployment, project, + utils.INSTALL_FCI_URL_ENDPOINT) + }, + { + "key": "volume-size-gb", + "value": utils.ConvertVolumeSizeString(volume_size_gb) + }, + { + "key": "is-ad-node", + "value": "true" + }, + { + "key": "is-master", + "value": "false" + }, + { + "key": "ad-domain", + "value": context.properties["domain"] + }, + { + "key": "domain-netbios", + "value": context.properties["domain_netbios"] + }, + { + "key": "ad-node-ip", + "value": utils.AdNodeIp(sql_cidr) + }, + { + "key": "service-account", + "value": service_account + }, + { + "key": "service-password", + "value": "$(ref.service-password.password)" + }, + { + "key": "safe-password", + "value": "$(ref.safe-password.password)" + }, + { + "key": "my-node-name", + "value": ad_node_name + }, + { + "key": "ad-node-name", + "value": ad_node_name + }, + { + "key": "all-nodes", + "value": utils.AllNodeNames(deployment, num_cluster_nodes) + }, + { + "key": "cluster-ip", + "value": utils.ClusterIp(sql_cidr) + }, + { + "key": "application-ip", + "value": utils.ApplicationIp(sql_cidr) + }, + { + "key": "zone", + "value": zone + }, + { + "key": "instance-group", + "value": utils.InstanceGroupName(deployment, zone) + }, + { + "key": "is-test", + "value": is_test + }, + { + "key": "sql-fci-deployment-tag", + "value": _SQL_FCI_TAG + }, + ] + } + + +def ClusterVm(context, image, machine_type, zone, node_num): + """Generates the config for a single cluster VM. + + Creates and returns a VM definition for a cluster node. The number of disks + is determined by the user. The network configuration is determined through + this nodes "node_num", which is a unique identifier for this node and so + guarantees a unique IP address. + + Args: + context: context of the deployment. + image: full path name of the image to use to boot. + machine_type: full path of the machine type. + zone: the zone where the VM will reside. + node_num: unique identifier of this VM. + + Raises: + VmInputValidationError: if the node name is too long. + + Returns: + VM definition of the cluster node. + """ + project = context.env["project"] + deployment = context.env["deployment"] + sql_cidr = context.properties.get("sql_cidr", utils.DEFAULT_DEPLOYMENT_CIDR) + net_name = utils.NetworkName(deployment) + sub_name = utils.SubnetName(deployment) + + volume_size_gb = utils.ConvertVolumeSizeString( + context.properties["volume_size_gb"]) + + if volume_size_gb not in _VALID_VOLUME_SIZES: + raise utils.VmInputValidationError( + "volume size unsupported. Volume size must be one of {valid_sizes}") + + vm_name = utils.NodeName(deployment, node_num) + + if len(vm_name) > _MAX_NAME_LEN: + raise utils.VmInputValidationError( + "Deployment name is too long. Node names are based on deployment name" + " and total length must be no longer than {} characters.".format( + _MAX_NAME_LEN)) + + resources = [] + disks = [BootDisk(vm_name, "cluster-boot-disk", project, zone, image)] + + # For each requested disk we need two disk definitions: + # 1) A top level disk definition to go into the "resources". This is used + # to create the disk. + # 2) A description of the disk to go in the instance definition. This is + # used to link the VM to a specific disk. + for disk_num in xrange(_NUM_PD_SSD_DISKS): + resources.append( + Disk(project, zone, vm_name, _PD_SSD_DISK, disk_num, + _VOLUME_TO_DISK_SIZE[volume_size_gb])) + disks.append(VmDisk(vm_name, _PD_SSD_DISK, disk_num)) + + nic = { + default.ACCESS_CONFIGS: [{ + default.NAME: "external-nat", + default.TYPE: default.ONE_NAT + }], + default.NETWORK: common.Ref(net_name), + default.SUBNETWORK: common.Ref(sub_name), + default.NETWORKIP: utils.NodeIp(sql_cidr, node_num) + } + + instance = { + default.ZONE: + zone, + # The service account is necessary for the VM to have access to google's + # API. This will be used in scripts that set/get data related to + # runtime watchers and configs. + default.SERVICE_ACCOUNTS: [{ + "email": + "default", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloudruntimeconfig" + ] + }], + default.MACHINETYPE: + machine_type, + default.DISKS: + disks, + default.NETWORK_INTERFACES: [nic], + default.METADATA: + BuildClusterInstanceMetadata(context, zone, node_num) + } + + # We want the master node (node 0) to come up first so that it is + # guaranteed to be the master. We do this by making all non-master nodes + # depend on node 0 + deps = [] if node_num == 0 else [utils.NodeName(deployment, 0)] + + resources.append({ + default.NAME: vm_name, + default.TYPE: default.INSTANCE, + default.METADATA: {"dependsOn": deps}, + default.PROPERTIES: instance + }) + + return resources + + +def AdVm(context, machine_type, zone, image): + """Creates a VM definition for the AD VM. + + This VM only has a single boot disk because it will not be a part of the + cluster. + + Args: + context: context of the deployment. + machine_type: full path of the machine type. + zone: The zone where the VM will reside. + image: full path of the image to boot. + + Returns: + definition of AD VM. + """ + + project = context.env["project"] + deployment = context.env["deployment"] + sql_cidr = context.properties.get("sql_cidr", utils.DEFAULT_DEPLOYMENT_CIDR) + net_name = utils.NetworkName(deployment) + sub_name = utils.SubnetName(deployment) + + ad_node_name = utils.AdNodeName(deployment) + + nic = { + default.ACCESS_CONFIGS: [{ + default.NAME: "external-nat", + default.TYPE: default.ONE_NAT, + }], + default.NETWORK: common.Ref(net_name), + default.SUBNETWORK: common.Ref(sub_name), + default.NETWORKIP: utils.AdNodeIp(sql_cidr) + } + + instance = { + default.ZONE: + zone, + default.MACHINETYPE: + machine_type, + default.SERVICE_ACCOUNTS: [{ + "email": + "default", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloudruntimeconfig" + ] + }], + default.DISKS: [ + BootDisk(ad_node_name, "cntlr-boot-disk", project, zone, image) + ], + default.NETWORK_INTERFACES: [nic], + default.METADATA: + BuildAdNodeInstanceMetadata(context, zone) + } + + return { + default.NAME: ad_node_name, + default.TYPE: default.INSTANCE, + default.PROPERTIES: instance + } + + +def ValidateVmContext(context): + """Raises an exception if some input error is found. + + Args: + context: Context of the deployment. + + Raises: + VmInputValidationError: if there is a problem with the input + """ + num_cluster_nodes = context.properties["num_cluster_nodes"] + + if not _MIN_CLUSTER_NODES <= num_cluster_nodes <= _MAX_CLUSTER_NODES: + raise utils.VmInputValidationError( + "Storage Space Direct requires at least " + "{min_nodes} and at most {max_nodes} nodes.".format( + min_nodes=_MIN_CLUSTER_NODES, max_nodes=_MAX_CLUSTER_NODES)) + + num_disks_with_node_down = _NUM_PD_SSD_DISKS * (num_cluster_nodes - 1) + if num_disks_with_node_down < _MIN_DISKS: + raise utils.VmInputValidationError( + ("S2D replication requires at least {num_disks} disks." + " If one of your nodes goes down, you will only have" + " {disks} in your deployment. Increase the number of " + " disks in your VMs or the number of VMs in your " + "deployment to have at least {num_disks} available " + "with a node down.").format( + num_disks=num_disks_with_node_down, disks=_MIN_DISKS)) + + +def GenerateConfig(context): + """Generates the config for the VMs in the deployment. + + Returns a dictionary with the configs constructed for the backend nodes + and the ad node in the deployment. There is also an instance group that + the backends are added to. + + Args: + context: Context of the deployment. + + Returns: + List of resources that the gcloud deployment-manager is to create. + """ + + ValidateVmContext(context) + + deployment = context.env["deployment"] + num_cluster_nodes = context.properties["num_cluster_nodes"] + region = context.properties["region"] + net_name = utils.NetworkName(deployment) + + # list of top level resources to be returned to the gcloud deployment + # orchestrator. Generate passwords for use in the windows apps install. + resources = [{ + default.NAME: "service-password", + default.TYPE: "password.py", + default.PROPERTIES: { + "length": 8, + "includeSymbols": False, + } + }, { + default.NAME: "safe-password", + default.TYPE: "password.py", + default.PROPERTIES: { + "length": 14, + "includeSymbols": True, + } + }] + + # list of instance names to be put in the instance group + instances = [] + + def _GetImagePath(family, image): + return "{}projects/{}/global/images/{}".format(default.COMPUTE_URL_BASE, + family, image) + + def _GetMachinePath(zone): + machine_type = utils.ConvertMachineTypeString( + context.properties["machine_type"]) + return "{}projects/{}/zones/{}/machineTypes/{}".format( + default.COMPUTE_URL_BASE, context.env["project"], zone, machine_type) + + for node_num in xrange(num_cluster_nodes): + zone = utils.GetNodeZoneFromRegion(region, node_num) + machine_type = _GetMachinePath(zone) + vm = ClusterVm(context, _GetImagePath( + _SQL_FCI_PUBLIC_FAMILY, _SQL_SVR_2016), machine_type, zone, node_num) + + resources.extend(vm) + instances.append(common.Ref(utils.NodeName(deployment, node_num))) + + default_zone = utils.GetDefaultZoneFromRegion(region) + resources.append( + AdVm(context, _GetMachinePath(default_zone), default_zone, + _GetImagePath(_SQL_FCI_PUBLIC_FAMILY, _WINDOWS_2016))) + + # The instance group will be used by the load balancer + for instance_group_zone in utils.GetZoneSet(region, num_cluster_nodes): + resources.append({ + default.NAME: utils.InstanceGroupName(deployment, instance_group_zone), + default.TYPE: default.INSTANCE_GROUP, + default.PROPERTIES: { + default.ZONE: instance_group_zone, + default.NETWORK: common.Ref(net_name), + } + }) + + return { + "resources": resources, + }