|
| 1 | +#!/usr/bin/env python |
| 2 | +# pylint: disable=duplicate-code |
| 3 | +""" |
| 4 | +Generate and deploy development templates for Azure deployment. |
| 5 | +
|
| 6 | +Enables SSH access to the VMs and installs the elastic-agent with the given version and enrollment token. |
| 7 | +""" |
| 8 | +import argparse |
| 9 | +import json |
| 10 | +import os |
| 11 | +import pathlib |
| 12 | +import shlex |
| 13 | +import subprocess |
| 14 | +import sys |
| 15 | +import time |
| 16 | + |
| 17 | + |
| 18 | +def main(): |
| 19 | + """ |
| 20 | + Parse arguments and run the script. |
| 21 | + """ |
| 22 | + args = parse_args(load_file_args() + sys.argv[1:]) |
| 23 | + |
| 24 | + with open(args.template_file) as f: |
| 25 | + template = json.load(f) |
| 26 | + |
| 27 | + modify_template(template) |
| 28 | + with open(args.output_file, "w") as f: |
| 29 | + print(json.dumps(template, indent=4), file=f) # Pretty-print the template in a JSON file. |
| 30 | + |
| 31 | + if args.deploy: |
| 32 | + if args.template_type == "organization-account": |
| 33 | + deploy_to_management_group(args) |
| 34 | + else: |
| 35 | + deploy_to_subscription(args) |
| 36 | + |
| 37 | + |
| 38 | +def load_file_args(): |
| 39 | + """ |
| 40 | + Load extra command-line arguments from a file. |
| 41 | + """ |
| 42 | + config_file = pathlib.Path(__file__).parent / "dev-flags.conf" |
| 43 | + if not config_file.exists(): |
| 44 | + return [] |
| 45 | + with open(config_file) as f: |
| 46 | + return shlex.split(f.read().strip()) |
| 47 | + |
| 48 | + |
| 49 | +def parse_args(argv): |
| 50 | + """ |
| 51 | + Parse command-line arguments. |
| 52 | + :param argv: The arguments |
| 53 | + :return: Parsed argparse namespace |
| 54 | + """ |
| 55 | + will_call_az_cli = "--deploy" in argv |
| 56 | + |
| 57 | + parser = argparse.ArgumentParser(description="Deploy Azure resources for a single account") |
| 58 | + parser.add_argument( |
| 59 | + "--template-type", |
| 60 | + help="The type of template to use", |
| 61 | + default="single-account", |
| 62 | + choices=["single-account", "organization-account"], |
| 63 | + ) |
| 64 | + parser.add_argument( |
| 65 | + "--output-file", |
| 66 | + help="The output file to write the modified template to", |
| 67 | + default=None, # Replace later |
| 68 | + ) |
| 69 | + parser.add_argument("--deploy", help="Perform deployment", action="store_true") |
| 70 | + parser.add_argument( |
| 71 | + "--resource-group", |
| 72 | + help="The resource group to deploy to", |
| 73 | + default=f"{os.environ.get('USER', 'unknown')}-cloudbeat-dev-{int(time.time())}", |
| 74 | + ) |
| 75 | + parser.add_argument("--location", help="The location to deploy to", default=os.environ.get("LOCATION", "centralus")) |
| 76 | + parser.add_argument("--subscription-id", help="The subscription ID to deploy to (defaults to current)") |
| 77 | + parser.add_argument("--management-group-id", help="The management group ID to deploy to") |
| 78 | + |
| 79 | + parser.add_argument("--public-ssh-key", help="SSH public key to use for the VMs", required=will_call_az_cli) |
| 80 | + parser.add_argument("--artifact-server", help="The URL of the artifact server", required=will_call_az_cli) |
| 81 | + parser.add_argument( |
| 82 | + "--elastic-agent-version", |
| 83 | + help="The version of elastic-agent to install", |
| 84 | + default=os.environ.get("ELK_VERSION", ""), |
| 85 | + ) |
| 86 | + parser.add_argument("--fleet-url", help="The fleet URL of elastic-agent", required=will_call_az_cli) |
| 87 | + parser.add_argument("--enrollment-token", help="The enrollment token of elastic-agent", required=will_call_az_cli) |
| 88 | + args = parser.parse_args(argv) |
| 89 | + |
| 90 | + if args.deploy != will_call_az_cli: |
| 91 | + parser.error("Assertion failed: --deploy detected but parser returned different result") |
| 92 | + |
| 93 | + args.template_file = pathlib.Path(__file__).parent / f"ARM-for-{args.template_type}.json" |
| 94 | + if args.output_file is None: |
| 95 | + args.output_file = str(args.template_file).replace(".json", ".dev.json") |
| 96 | + if args.template_type == "single-account" and args.management_group_id is not None: |
| 97 | + parser.error("Cannot specify management group for single-account template") |
| 98 | + elif args.deploy and args.template_type == "organization-account" and args.management_group_id is None: |
| 99 | + parser.error("Must specify management group for organization-account template") |
| 100 | + |
| 101 | + return args |
| 102 | + |
| 103 | + |
| 104 | +def modify_template(template): |
| 105 | + """ |
| 106 | + Modify the template in-place. |
| 107 | + :param template: Parsed dictionary of the template |
| 108 | + """ |
| 109 | + template["parameters"]["PublicKeyDevOnly"] = { |
| 110 | + "type": "string", |
| 111 | + "metadata": {"description": "The public key of the SSH key pair"}, |
| 112 | + } |
| 113 | + |
| 114 | + # Shallow copy of all resources and resources of deployments |
| 115 | + all_resources = template["resources"][:] |
| 116 | + for resource in template["resources"]: |
| 117 | + if resource["type"] == "Microsoft.Resources/deployments": |
| 118 | + all_resources += resource["properties"]["template"]["resources"] |
| 119 | + for resource in all_resources: |
| 120 | + modify_resource(resource) |
| 121 | + |
| 122 | + |
| 123 | +def modify_resource(resource): |
| 124 | + """ |
| 125 | + Modify a single resource in-place. |
| 126 | + :param resource: Parsed dictionary of the resource |
| 127 | + """ |
| 128 | + # Delete generated key pair from all dependencies |
| 129 | + depends_on = [d for d in resource.get("dependsOn", []) if not d.startswith("cloudbeatGenerateKeypair")] |
| 130 | + |
| 131 | + if resource["name"] == "cloudbeatVM": |
| 132 | + # Use user-provided public key |
| 133 | + resource["properties"]["osProfile"]["linuxConfiguration"]["ssh"]["publicKeys"] = [ |
| 134 | + { |
| 135 | + "path": "/home/cloudbeat/.ssh/authorized_keys", |
| 136 | + "keyData": "[parameters('PublicKeyDevOnly')]", |
| 137 | + }, |
| 138 | + ] |
| 139 | + elif resource["name"] == "cloudbeatVNet": |
| 140 | + # Add network security group to virtual network |
| 141 | + nsg_resource_id = "[resourceId('Microsoft.Network/networkSecurityGroups', 'cloudbeatNSGDevOnly')]" |
| 142 | + resource["properties"]["subnets"][0]["properties"]["networkSecurityGroup"] = {"id": nsg_resource_id} |
| 143 | + depends_on += [nsg_resource_id] |
| 144 | + elif resource["name"] == "cloudbeatNic": |
| 145 | + # Add public IP to network interface |
| 146 | + public_ip_resource_id = "[resourceId('Microsoft.Network/publicIPAddresses', 'cloudbeatPublicIPDevOnly')]" |
| 147 | + resource["properties"]["ipConfigurations"][0]["properties"]["publicIpAddress"] = {"id": public_ip_resource_id} |
| 148 | + depends_on += [public_ip_resource_id] |
| 149 | + elif resource["name"] == "cloudbeatVM/customScriptExtension": |
| 150 | + # Modify agent installation to *not* disable SSH |
| 151 | + resource["properties"]["settings"] = { |
| 152 | + "fileUris": ["https://raw.githubusercontent.com/elastic/cloudbeat/main/deploy/azure/install-agent-dev.sh"], |
| 153 | + "commandToExecute": ( |
| 154 | + "[concat('" |
| 155 | + "bash install-agent-dev.sh ', " |
| 156 | + "parameters('ElasticAgentVersion'), ' ', " |
| 157 | + "parameters('ElasticArtifactServer'), ' ', " |
| 158 | + "parameters('FleetUrl'), ' ', " |
| 159 | + "parameters('EnrollmentToken'))]" |
| 160 | + ), |
| 161 | + } |
| 162 | + elif resource["name"] == "cloudbeat-vm-deployment": |
| 163 | + resource["properties"]["parameters"] = {"PublicKeyDevOnly": {"value": "[parameters('PublicKeyDevOnly')]"}} |
| 164 | + resource["properties"]["template"]["parameters"] = {"PublicKeyDevOnly": {"type": "string"}} |
| 165 | + modify_vm_deployment_template_resources_array(resource["properties"]["template"]) |
| 166 | + |
| 167 | + if depends_on: |
| 168 | + resource["dependsOn"] = depends_on |
| 169 | + |
| 170 | + |
| 171 | +def modify_vm_deployment_template_resources_array(template): |
| 172 | + """ |
| 173 | + Modify the resources array of the cloudbeat VM deployment template in-place. |
| 174 | + :param template: Parsed dictionary of the template |
| 175 | + """ |
| 176 | + template["resources"] = [ |
| 177 | + resource |
| 178 | + for resource in template["resources"] |
| 179 | + # Delete generated key pair since we provide our own |
| 180 | + if resource["name"] != "cloudbeatGenerateKeypair" |
| 181 | + ] + [ |
| 182 | + { |
| 183 | + "type": "Microsoft.Network/publicIPAddresses", |
| 184 | + "name": "cloudbeatPublicIpDevOnly", |
| 185 | + "apiVersion": "2020-05-01", |
| 186 | + "location": "[resourceGroup().location]", |
| 187 | + "properties": {"publicIPAllocationMethod": "Dynamic"}, |
| 188 | + }, |
| 189 | + { |
| 190 | + "type": "Microsoft.Network/networkSecurityGroups", |
| 191 | + "name": "cloudbeatNSGDevOnly", |
| 192 | + "apiVersion": "2021-04-01", |
| 193 | + "location": "[resourceGroup().location]", |
| 194 | + "properties": { |
| 195 | + "securityRules": [ |
| 196 | + { |
| 197 | + "name": "AllowSshAll", |
| 198 | + "properties": { |
| 199 | + "access": "Allow", |
| 200 | + "destinationAddressPrefix": "*", |
| 201 | + "destinationPortRange": "22", |
| 202 | + "direction": "Inbound", |
| 203 | + "priority": 100, |
| 204 | + "protocol": "Tcp", |
| 205 | + "sourceAddressPrefix": "*", |
| 206 | + "sourcePortRange": "*", |
| 207 | + }, |
| 208 | + }, |
| 209 | + ], |
| 210 | + }, |
| 211 | + }, |
| 212 | + ] |
| 213 | + |
| 214 | + |
| 215 | +def deploy_to_subscription(args): |
| 216 | + """ |
| 217 | + Deploy the template to a subscription. |
| 218 | + :param args: The parsed arguments |
| 219 | + """ |
| 220 | + parameters = parameters_from_args(args) |
| 221 | + subscription_args = ["--subscription", args.subscription_id] if args.subscription_id else [] |
| 222 | + subprocess.check_call( |
| 223 | + [ |
| 224 | + "az", |
| 225 | + "group", |
| 226 | + "create", |
| 227 | + "--name", |
| 228 | + args.resource_group, |
| 229 | + "--location", |
| 230 | + args.location, |
| 231 | + ] |
| 232 | + + subscription_args, |
| 233 | + ) |
| 234 | + subprocess.check_call( |
| 235 | + [ |
| 236 | + "az", |
| 237 | + "deployment", |
| 238 | + "group", |
| 239 | + "create", |
| 240 | + "--resource-group", |
| 241 | + args.resource_group, |
| 242 | + "--template-file", |
| 243 | + args.output_file, |
| 244 | + "--parameters", |
| 245 | + json.dumps(parameters), |
| 246 | + ] |
| 247 | + + subscription_args, |
| 248 | + ) |
| 249 | + |
| 250 | + |
| 251 | +def deploy_to_management_group(args): |
| 252 | + """ |
| 253 | + Deploy the template to a management group. |
| 254 | + :param args: The parsed arguments |
| 255 | + """ |
| 256 | + parameters = parameters_from_args(args) |
| 257 | + parameters["parameters"]["ResourceGroupName"] = {"value": args.resource_group} |
| 258 | + if args.subscription_id is None: |
| 259 | + args.subscription_id = ( |
| 260 | + subprocess.check_output(["az", "account", "show", "--query", "id", "-o", "tsv"]) |
| 261 | + .decode( |
| 262 | + "utf-8", |
| 263 | + ) |
| 264 | + .strip() |
| 265 | + ) |
| 266 | + parameters["parameters"]["SubscriptionId"] = {"value": args.subscription_id} |
| 267 | + subprocess.check_call( |
| 268 | + [ |
| 269 | + "az", |
| 270 | + "deployment", |
| 271 | + "mg", |
| 272 | + "create", |
| 273 | + "--location", |
| 274 | + args.location, |
| 275 | + "--template-file", |
| 276 | + args.output_file, |
| 277 | + "--parameters", |
| 278 | + json.dumps(parameters), |
| 279 | + "--management-group-id", |
| 280 | + args.management_group_id, |
| 281 | + ], |
| 282 | + ) |
| 283 | + |
| 284 | + |
| 285 | +def parameters_from_args(args): |
| 286 | + """ |
| 287 | + Generate the deployment parameters file from the parsed arguments. |
| 288 | + :param args: The parsed arguments |
| 289 | + :return: |
| 290 | + """ |
| 291 | + return { |
| 292 | + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", |
| 293 | + "contentVersion": "1.0.0.0", |
| 294 | + "parameters": { |
| 295 | + "ElasticArtifactServer": {"value": args.artifact_server}, |
| 296 | + "ElasticAgentVersion": {"value": args.elastic_agent_version}, |
| 297 | + "FleetUrl": {"value": args.fleet_url}, |
| 298 | + "EnrollmentToken": {"value": args.enrollment_token}, |
| 299 | + "PublicKeyDevOnly": {"value": args.public_ssh_key}, |
| 300 | + }, |
| 301 | + } |
| 302 | + |
| 303 | + |
| 304 | +if __name__ == "__main__": |
| 305 | + main() |
0 commit comments