Skip to content

Commit a1294f0

Browse files
authored
[Asset Inventory][AWS & Azure] Support organization account deployment type (#2591)
1 parent 53641fd commit a1294f0

24 files changed

+3155
-19
lines changed

.github/workflows/ci-pull_request.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ jobs:
9494
run: |
9595
go install gotest.tools/gotestsum
9696
GOOS=linux TEST_DIRECTORY=./... gotestsum --format pkgname -- -race -coverpkg=./... -coverprofile=cover.out.tmp
97-
cat cover.out.tmp | grep -v "mock_.*.go" > cover.out # remove mock files from coverage report
97+
cat cover.out.tmp | grep -v "mock_.*.go" | grep -v "elastic/cloudbeat/deploy" > cover.out # remove mock files and deploy dir
9898
9999
- name: Upload coverage artifact
100100
uses: actions/upload-artifact@v4

.github/workflows/publish-cloudformation.yml

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
- main
77
- "[0-9]+.[0-9]+"
88
paths:
9+
- deploy/asset-inventory-cloudformation/*.yml
910
- deploy/cloudformation/*.yml
1011
- scripts/publish_cft.sh
1112
- .github/workflows/publish-cloudformation.yml

deploy/asset-inventory-arm/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dev-flags.conf

deploy/asset-inventory-arm/ARM-for-organization-account.dev.json

+444
Large diffs are not rendered by default.

deploy/asset-inventory-arm/ARM-for-organization-account.json

+450
Large diffs are not rendered by default.

deploy/asset-inventory-arm/ARM-for-single-account.dev.json

+368
Large diffs are not rendered by default.

deploy/asset-inventory-arm/ARM-for-single-account.json

+359
Large diffs are not rendered by default.

deploy/asset-inventory-arm/README.md

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
## ARM deployment for developers
2+
3+
The [`generate_dev_template.py`](./generate_dev_template.py) script generates an ARM template for deploying the Elastic
4+
Agent with SSH access enabled to the VM. This script works both for the single subscription and management group
5+
templates.
6+
7+
Usage:
8+
9+
```text
10+
usage: generate_dev_template.py [-h]
11+
[--template-type {single-account,organization-account}]
12+
[--output-file OUTPUT_FILE] [--deploy]
13+
[--resource-group RESOURCE_GROUP]
14+
[--public-ssh-key PUBLIC_SSH_KEY]
15+
[--artifact-server ARTIFACT_SERVER]
16+
[--elastic-agent-version ELASTIC_AGENT_VERSION]
17+
[--fleet-url FLEET_URL]
18+
[--enrollment-token ENROLLMENT_TOKEN]
19+
20+
Deploy Azure resources for a single account
21+
22+
options:
23+
-h, --help show this help message and exit
24+
--template-type {single-account,organization-account}
25+
The type of template to use
26+
--output-file OUTPUT_FILE
27+
The output file to write the modified template to
28+
--deploy Perform deployment
29+
--resource-group RESOURCE_GROUP
30+
The resource group to deploy to
31+
--public-ssh-key PUBLIC_SSH_KEY
32+
SSH public key to use for the VMs
33+
--artifact-server ARTIFACT_SERVER
34+
The URL of the artifact server
35+
--elastic-agent-version ELASTIC_AGENT_VERSION
36+
The version of elastic-agent to install
37+
--fleet-url FLEET_URL
38+
The fleet URL of elastic-agent
39+
--enrollment-token ENROLLMENT_TOKEN
40+
The enrollment token of elastic-agent
41+
```
42+
43+
Arguments are also read from the `dev-flags.conf` file in the same directory as the script. Write the arguments in the
44+
file as you would pass them to the script. Notice that you need to properly quote arguments. Example:
45+
46+
```text
47+
--artifact-server https://snapshots.elastic.co/8.12.0-t9e0i58r/downloads/beats/elastic-agent
48+
--elastic-agent-version 8.12.0-SNAPSHOT
49+
--fleet-url <fleet url>
50+
--enrollment-token <enrollment token>
51+
--public-ssh-key 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3e38/Q26WUsyUVb4D7N1McL9QbrcamMfZw23+txivvP13QXzIEyvMjsqUpX0kqjg+C4OD7osfZ+wlVI3QFkomjDjjPMx/FYGUGk5ZKvKh9vXyxN2brYZq8C24lWQSpZbmvNF4+FueFx1eo6wMllLzmzzQ60LpeBhNhRiDPiLQKBotDn1mD6zymnhSANpS/+rWX5HVguSQgtEZP4vvxpKVxEM8hnT8V0PvWFfuNQpTf7zVpZtFvGTLoosvvGbQ27wiufHdF8vv9mF5cXhy02N4IaREcJEMu5wmQaD7zUcJ67aN4v7FTwkA6D3sppb7cJolUJJiOWh4kt7K03BEBYIM9g88lhHDFxwpUvMNWhwp/RHnu8/Ic3HL623W5EDcXxsjH1gsIpXtNuSaUP6G+c2k1zvmST7Oom6EXLT47hv9MXWcS7zY1YZtqVlboZiBRH5MfqwRPFHl6r04yqq1vithW/LeBweH8/q4iWaVYABda0Zmq8qFKKu/5VZStqbOt5wa0bIZrMn+dU6NUHlP6gOuM1yb7kbR2Y/x7AnHvNZ8YtcXDmoMjX93/7A+4Dr3qZd0FKtVoYqUspg0jOGH/Kj3sswp7oM98yJz5F/3/7VwSdzO/DzSGr9Of9BLCQHfcS6qJUZjsErPDqc0T7v7c+Dsz73t5zYq8uYovtUt6m3Anw== user@hostname'
52+
--deploy
53+
```
54+
55+
Executing the deployment with `--deploy` requires the `az` CLI to be installed and logged in to the correct
56+
subscription.
57+
58+
The script is included the pre-commit pipelines so new dev templates will be generated each time a change is made to the
59+
source templates.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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

Comments
 (0)