Repository navigation
Expand file tree
/
Copy pathmake_instance.py
More file actions
executable file
·1556 lines (1384 loc) · 65.1 KB
/
Copy pathmake_instance.py
File metadata and controls
executable file
·1556 lines (1384 loc) · 65.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
#
################################################################################
# Name: make_instance.py
# Author: Rodney Marable <rodney.marable@gmail.com>
# Created On: June 3, 2019
# Last Changed: September 28, 2019
# Purpose: Generic command-line EC2 instance creator
################################################################################
# Load the required Python libraries.
import argparse
import dataclasses
import functools
import os
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass
from math import pi
from typing import Literal, NoReturn
import boto3
# Import some external lists and functions.
# Source: aux_data.py
from aux_data import (
add_inbound_security_group_rule,
base_os_instance_check,
check_custom_ami,
cleanup_partial_build,
ctrlC_Abort,
ebs_encryption_check,
ec2_placement_group_check,
get_ami_info,
get_base_os_family,
get_instance_type_info,
illegal_az_msg,
log_retention_days_check,
modify_iam_policy_document,
p_fail,
p_val,
print_TextHeader,
refer_to_docs_and_quit,
report_cleanup_failures,
)
from instance_builder import (
AwsClients,
InstanceParameters,
abort_if_vars_file_exists,
apply_terraform,
build_security_group_tags,
build_sns_message,
build_windows_password_table,
compute_buffered_spot_price,
create_aws_clients,
create_sns_topic_and_subscribe,
decrypt_windows_admin_passwords,
ensure_state_directories,
fetch_spot_price_raw,
fetch_windows_instance_details,
generate_instance_serial_number,
generate_sns_timestamps,
get_terraform_version,
instance_lock,
publish_sns_notification,
resolve_ami,
resolve_custom_user_scripts,
resolve_ebs_optimized_support,
resolve_placement_group_strategy,
resolve_request_type_pricing,
resolve_security_group,
resolve_ssh_allowed_ips,
resolve_vpc_and_subnet,
setup_cloudwatch_logging,
setup_iam,
setup_keypair,
validate_and_resize_ebs_volumes,
validate_az_and_region,
validate_custom_ami_format,
validate_ec2_keypair_format,
validate_email_format,
validate_free_text_field,
validate_iam_name_lengths,
validate_iam_name_prefix_format,
validate_instance_name_and_owner_format,
write_serial_number_file,
write_vars_file,
)
from template_engine import render_instance_templates
# Type aliases used throughout this module's signatures -- same duplicated
# convention as instance_builder.py/aux_data.py/manage_instance.py/
# access_instance.py (see the comment there for why they're not shared via
# import).
BoolStr = Literal["true", "false"]
QuitFn = Callable[[str], NoReturn]
# Function: _positive_int()
# Purpose: argparse `type` for --count.
#
# A bare `type=int` accepted 0 and negative values. --count 0 was the
# interesting one: it skipped the count == 1 placement-group guard,
# rendered `count = 0` into the Terraform config, and then "succeeded" --
# having created a security group, keypair, IAM role/policy/profile, SNS
# topic and CloudWatch log group, and zero instances. Rejecting it at the
# argparse layer also covers mcp_server.build_instance, which reaches
# run_build() by building an argv list rather than by calling the phase
# functions directly.
def _non_negative_float(value: str) -> float:
# A negative buffer *lowers* the bid below the market price, so the Spot
# request can never be fulfilled -- --spot_buffer -1 produced a bid of
# exactly 0.0. The build then waits forever for capacity that will not
# arrive.
try:
parsed = float(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value!r} is not a number") from None
if parsed < 0:
raise argparse.ArgumentTypeError(f"must not be negative (got {parsed})")
return parsed
def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value!r} is not an integer") from None
if parsed < 1:
raise argparse.ArgumentTypeError(f"must be 1 or greater (got {parsed})")
return parsed
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="make_instance.py: Command-line interface to build EC2 instances")
# Configure parser arguments for the required variables.
parser.add_argument("--az", "-A", help="AWS Availability Zone (REQUIRED)", required=True)
parser.add_argument("--instance_name", "-N", help="name of the instance(s) (REQUIRED)", required=True)
parser.add_argument("--instance_owner", "-O", help="ActiveDirectory username of the instance_owner (REQUIRED)", required=True)
parser.add_argument("--instance_owner_email", "-E", help="Email address of the instance_owner (REQUIRED)", required=True)
# Parse values for the optional parameters from the commnand linue.
parser.add_argument(
"--base_os",
"-B",
choices=[
"al2023",
"alinux2",
"alma9",
"alma10",
"rhel9",
"rhel10",
"rocky9",
"rocky10",
"ubuntu2404",
"ubuntu2604",
"opensuse16",
"debian12",
"debian13",
"windows2019",
"windows2022",
"windows2025",
],
help="instance base operating system (default = al2023 a.k.a. Amazon Linux 2023)",
required=False,
default="al2023",
)
parser.add_argument("--count", "-C", help="number of EC2 instances to create (default = 1)", type=_positive_int, required=False, default=1)
parser.add_argument("--custom_ami", help="ami-id of a custom Amazon Machine Image (default = UNDEFINED)", required=False, default="UNDEFINED")
parser.add_argument(
"--custom_user_scripts",
help="comma-separated list of custom_user_scripts/ names to run (default = default); each name needs custom_user_prelogin_script.j2_<name> and/or custom_user_postboot_script.j2_<name> to exist",
required=False,
default="default",
)
parser.add_argument("--debug_mode", "-D", choices=["true", "false"], help="Enable debug mode (default = false)", required=False, default="false")
parser.add_argument("--ebs_encryption", choices=["true", "false"], help="enable EBS encryption where possible (default = false)", required=False, default="false")
parser.add_argument("--ebs_optimized", choices=["true", "false"], help="use optimized EBS volumes (default = yes)", required=False, default="true")
parser.add_argument("--ebs_root_volume_iops", help="amount of provisioned IOPS for the EBS root volume when ebs_root_volume_type=io1 (default = 0)", required=False, type=int, default=0)
parser.add_argument("--ebs_root_volume_size", help="EBS volume size in GB (Linux default = 8, Windows default = 30)", required=False, type=int, default=8)
parser.add_argument("--ebs_root_volume_type", choices=["gp2", "io1", "st1"], help="EBS volume type (default = gp2)", required=False, default="gp2")
parser.add_argument("--ebs_device_volume_iops", help="amount of provisioned IOPS for the EBS secondary volume when ebs_root_volume_type=io1 (default = 0)", required=False, type=int, default=0)
parser.add_argument("--ebs_device_volume_size", help="Secondary EBS volume size in GB (Linux default = 8, Windows default = 30)", required=False, type=int, default=8)
parser.add_argument("--ebs_device_volume_type", choices=["gp2", "io1", "st1"], help="EBS secondary volume type (default = gp2)", required=False, default="gp2")
parser.add_argument("--ec2_keypair", help="define an EC2 key pair name to provide SSH or Remote Desktop access (default = ec2_keypair_default)", required=False, default="ec2_keypair_default")
parser.add_argument(
"--enable_placement_group",
"--enable_pg",
choices=["true", "false"],
help='Place the new instances in an EC2 placement group using the "cluster" strategy (default = false)',
required=False,
default="false",
)
parser.add_argument("--hyperthreading", "-H", choices=["true", "false"], help="enable Intel Hyperthreading (default = true)", required=False, default="true")
parser.add_argument(
"--iam_json_policy",
"-J",
choices=["MinimalEc2InstancePolicy.json", "GenericEc2InstancePolicy.json", "ExtendedEc2InstancePolicy.json"],
help="Use a pre-existing JSON policy document in the /templates subdirectory to set permissions for iam_role (default = GenericEc2InstancePolicy.json",
required=False,
default="GenericEc2InstancePolicy.json",
)
parser.add_argument("--iam_name_prefix", help="Provide a prefix for the IAM entities associated with the instance (default = Ec2InstanceMaker)", required=False, default="Ec2InstanceMaker")
parser.add_argument("--iam_role", help="Apply a pre-existing IAM role to the instance(s)", required=False, default="UNDEFINED")
parser.add_argument(
"--instance_owner_department",
help="Department of the instance_owner (default = compbio)",
required=False,
default="compbio",
)
parser.add_argument("--request_type", choices=["ondemand", "spot"], help="choose between ondemand or spot instances (default = ondemand)", required=False, default="ondemand")
parser.add_argument(
"--instance_type",
"-T",
help="EC2 instance type (default = t2.micro); CPU architecture (x86_64 or Graviton/ARM64) is auto-detected, no separate flag needed",
required=False,
default="t2.micro",
)
parser.add_argument("--prod_level", choices=["dev", "test", "stage", "prod"], help="Operating stage of the jumphost (default = dev)", required=False, default="dev")
parser.add_argument(
"--enable_cloudwatch_logs",
choices=["true", "false"],
help="Install and configure the CloudWatch Agent on the instance(s) to ship logs to CloudWatch Logs (default = true)",
required=False,
default="true",
)
parser.add_argument(
"--enable_rockysurf",
choices=["true", "false"],
help=(
"Install Node.js and run RockySurf (npx -y rockysurf@0.1.5 --port 3033, loopback-only, "
"as a systemd service) on the instance(s) for a browser-based coding environment; Linux "
"only, ignored for Windows base_os values (default = false). RockySurf is itself a "
"cloud-provisioning tool that inherits this instance's IAM role/credentials automatically "
"-- see README.md's RockySurf section and "
"https://github.com/amroja-biz/rockysurf/blob/main/SECURITY.md before enabling on a "
"Generic/Extended --iam_json_policy instance"
),
required=False,
default="false",
)
parser.add_argument(
"--log_retention_days",
type=int,
help="Number of days to retain CloudWatch Logs for the instance(s) (default = 30)",
required=False,
default=30,
)
parser.add_argument(
"--placement_group_strategy", "--pg_strategy", choices=["cluster", "spread"], help="Designate an EC2 placement group strategy (default = cluster)", required=False, default="cluster"
)
parser.add_argument("--preserve_ami", choices=["true", "false"], help="Preserve any AMI image built from the instance(s) post-termination (default = true)", required=False, default="true")
parser.add_argument(
"--preserve_cloudwatch_logs",
choices=["true", "false"],
help="Preserve the CloudWatch Logs group when the instance(s) are terminated (default = false)",
required=False,
default="false",
)
parser.add_argument(
"--rollback_on_failure",
choices=["true", "false"],
help="Automatically tear down anything this build created if any phase fails (default = false)",
required=False,
default="false",
)
parser.add_argument("--project_id", "-P", help="Project name or ID number (default = UNDEFINED)", required=False, default="UNDEFINED")
parser.add_argument("--public_ip", "-p", choices=["true", "false"], help="Attach a public IP address to the instance(s) (default = true)", required=False, default="true")
parser.add_argument("--security_group", "-S", help="Primary security group name for the EC2 instance (default = ec2instancemaker_sg)", required=False, default="ec2instancemaker_sg")
parser.add_argument(
"--spot_buffer",
help="pricing buffer to protect from Spot market fluctuations: spot_price = spot_price + spot_price*spot_buffer",
type=_non_negative_float,
required=False,
default=round((1 / pi), 8),
)
parser.add_argument(
"--ssh_allowed_ips",
help="CIDR block allowed to reach the instance's SSH/RDP port (default = the CIDR of the instance's own VPC). Never accepts 0.0.0.0/0.",
required=False,
default="UNDEFINED",
)
parser.add_argument("--turbot_account", help="Turbot account ID (default = DISABLED)", required=False, default="DISABLED")
parser.add_argument("--vpc_name", help="Name of the VPC (default = vpc_default)", required=False, default="vpc_default")
return parser.parse_args(argv)
# Function: print_debug_parameters()
# Purpose: print the current values of all defined instance_parameters to
# the console when --debug_mode=true. Reads a single typed
# InstanceParameters instance instead of ~50 separate local variables --
# was inline in main() before instance_parameters became a dataclass (see
# CLAUDE-STATE.md), where a signature this wide would've been unreadable.
def print_debug_parameters(params: InstanceParameters) -> None:
print_TextHeader(params.instance_name, "Printing", 80)
print("aws_account_id = " + params.aws_account_id)
if params.turbot_account != "DISABLED":
print("turbot_account = " + params.turbot_account)
print("aws_ami = " + str(params.aws_ami))
print("az = " + params.az)
print("base_os = " + params.base_os)
print("is_windows = " + str(params.is_windows))
print("package_manager = " + str(params.package_manager))
print("awscli_preinstalled = " + str(params.awscli_preinstalled))
if params.count > 1:
print("count = " + str(params.count))
print("ebs_encryption = " + str(params.ebs_encryption))
print("ebs_optimized = " + str(params.ebs_optimized))
print("ebs_root_volume_size = " + str(params.ebs_root_volume_size))
print("ebs_root_volume_type = " + params.ebs_root_volume_type)
print("ebs_root_volume_iops = " + str(params.ebs_root_volume_iops))
print("ebs_device_volume_size = " + str(params.ebs_device_volume_size))
print("ebs_device_volume_type = " + params.ebs_device_volume_type)
print("ebs_device_volume_iops = " + str(params.ebs_device_volume_iops))
print("instance_type = " + params.instance_type)
print("architecture = " + params.architecture)
print("ec2_keypair = " + params.ec2_keypair)
print("ec2_user = " + params.ec2_user)
print("ec2_user_home = " + params.ec2_user_home)
if params.enable_placement_group == "true":
print("enable_placement_group = " + params.enable_placement_group)
print("placement_group_strategy = " + params.placement_group_strategy)
print("hyperthreading = " + params.hyperthreading)
print("instance_name = " + params.instance_name)
print("instance_owner = " + params.instance_owner)
print("instance_owner_email = " + params.instance_owner_email)
print("instance_owner_department = " + params.instance_owner_department)
print("instance_serial_number = " + params.instance_serial_number)
print("instance_serial_number_file = " + params.instance_serial_number_file)
print("request_type = " + params.request_type)
print("preserve_ami = " + params.preserve_ami)
print("prod_devel = " + params.prod_level)
if params.project_id != "UNDEFINED":
print("project_id = " + params.project_id)
print("iam_name_prefix = " + params.iam_name_prefix)
if params.ec2_iam_instance_profile:
print("preserve_iam_role = " + params.preserve_iam_role)
print("preserve_security_group = " + params.preserve_security_group)
if "UNDEFINED" not in params.ec2_iam_instance_policy:
print("ec2_iam_instance_policy = " + params.ec2_iam_instance_policy)
print("ec2_iam_instance_profile = " + params.ec2_iam_instance_profile)
print("ec2_iam_instance_role = " + params.ec2_iam_instance_role)
if params.rockysurf_boundary_policy:
print("rockysurf_boundary_policy = " + params.rockysurf_boundary_policy)
print("public_ip = " + params.public_ip)
print("ssh_allowed_ips = " + params.ssh_allowed_ips)
print("region = " + params.region)
print("security_group_name = " + str(params.security_group_name))
print("spot_price = " + str(params.spot_price))
print("subnet_id = " + params.subnet_id)
print("vars_file_path = " + params.vars_file_path)
print("vpc_id = " + params.vpc_id)
print("vpc_name = " + params.vpc_name)
print("vpc_security_group_ids = " + params.vpc_security_group_ids)
print("sns_topic_arn = " + params.sns_topic_arn)
print("sns_datestamp = " + params.sns_datestamp)
print("sns_timestamp = " + params.sns_timestamp)
print("enable_cloudwatch_logs = " + params.enable_cloudwatch_logs)
if params.enable_cloudwatch_logs == "true":
print("cloudwatch_log_group = " + params.cloudwatch_log_group)
print("log_retention_days = " + str(params.log_retention_days))
print("preserve_cloudwatch_logs = " + params.preserve_cloudwatch_logs)
print("enable_rockysurf = " + params.enable_rockysurf)
print("custom_user_prelogin_scripts = " + ", ".join(params.custom_user_prelogin_scripts))
print("custom_user_postboot_scripts = " + ", ".join(params.custom_user_postboot_scripts))
print("instance_data_dir = " + params.instance_data_dir)
print("debug_mode = " + params.debug_mode)
print("DEPLOYMENT_DATE = " + params.DEPLOYMENT_DATE)
print("DEPLOYMENT_DATE_TAG = " + params.DEPLOYMENT_DATE_TAG)
print("TERRAFORM_VERSION = " + params.TERRAFORM_VERSION)
################################################################################
# main()'s build flow, phased.
#
# main() used to be one ~650-line linear sequence threading ~50 local
# variables through every step. Every AWS/Terraform-touching function it
# calls already lives in instance_builder.py/aux_data.py, fully typed and
# independently tested (tests/test_instance_builder.py) -- what was left to
# clean up here was main()'s own orchestration: too many independent
# variables in play at once to read as a sequence of named steps.
#
# The 5 phase functions below stay in make_instance.py itself (NOT
# instance_builder.py) -- this is required, not a style choice:
# tests/test_make_instance_integration.py monkeypatches individual
# AWS-touching functions via monkeypatch.setattr(make_instance,
# "resolve_vpc_and_subnet", ...), which only intercepts calls made via
# attribute lookup on this module's own namespace. A phase function calling
# resolve_vpc_and_subnet(...) from inside instance_builder.py would bypass
# that patched attribute entirely and silently stop being covered by the
# existing tests.
#
# BuildSettings bundles every value that's established once from argparse
# (or derived once, early) and never reassigned afterward -- the "who,
# where, what" of one build, threaded unchanged through all 5 phases.
# EbsRequest/BuildOptions bundle smaller, related CLI-input clusters the
# same way. Each phase's own *_Resolution dataclass carries only the new
# values that phase actually resolves via AWS calls (or leaves reassigned,
# e.g. placement_group_strategy) -- passed whole to the next phase that
# needs them, instead of unpacked field-by-field.
################################################################################
@dataclass
class BuildSettings:
instance_name: str
instance_serial_number: str
instance_serial_number_file: str
instance_data_dir: str
vars_file_path: str
region: str
az: str
debug_mode: BoolStr
instance_owner: str
instance_owner_email: str
instance_owner_department: str
instance_type: str
base_os: str
count: int
request_type: Literal["ondemand", "spot"]
enable_placement_group: BoolStr
enable_cloudwatch_logs: BoolStr
enable_rockysurf: BoolStr
log_retention_days: int
iam_name_prefix: str
turbot_account: str
DEPLOYMENT_DATE: str
DEPLOYMENT_DATE_TAG: str
TERRAFORM_VERSION: str
@dataclass
class EbsRequest:
encryption: BoolStr
optimized: BoolStr
root_volume_size: int
root_volume_type: str
root_volume_iops: int
device_volume_size: int
device_volume_type: str
device_volume_iops: int
@dataclass
class BuildOptions:
preserve_ami: BoolStr
preserve_cloudwatch_logs: BoolStr
hyperthreading: BoolStr
prod_level: Literal["dev", "test", "stage", "prod"]
project_id: str
public_ip: BoolStr
@dataclass
class NetworkAndComputeResolution:
architecture: str
is_windows: bool
package_manager: str | None
awscli_preinstalled: bool | None
ec2_user: str
ebs_optimized: BoolStr
ebs_root_volume_size: int
ebs_device_volume_size: int
spot_price: str | float
placement_group_strategy: str
@dataclass
class VpcSecurityAndKeypairResolution:
aws_account_id: str
vpc_id: str
vpc_name: str
subnet_id: str
ssh_allowed_ips: str
security_group_name: str
vpc_security_group_ids: str
preserve_security_group: BoolStr
ec2_user_home: str
aws_ami: str
ec2_keypair: str
@dataclass
class IamSnsAndLoggingResolution:
cloudwatch_log_group: str
ec2_iam_instance_role: str
ec2_iam_instance_policy: str
ec2_iam_instance_profile: str
preserve_iam_role: BoolStr
rockysurf_boundary_policy: str
sns_topic_arn: str
sns_datestamp: str
sns_timestamp: str
# BuildReport is run_build()'s return value -- the same information
# report_and_notify() prints to the console for a CLI operator, structured
# for a programmatic caller (mcp_server.py's build_instance tool) instead.
@dataclass
class BuildReport:
instance_name: str
count: int
is_windows: bool
access_command: str | None
# Deliberately never populated with the password table itself -- see
# report_and_notify(). Kept as a field so a programmatic caller can
# tell a Windows build from a Linux one and knows where to look.
windows_password_retrieval_command: str | None
kill_script: str
build_ami_script: str
sns_topic_arn: str
rockysurf_enabled: bool
# A programmatic caller (mcp_server.py's build_instance tool) has no
# console to read the printed tunnel command from -- one real,
# ready-to-run "aws ssm start-session ..." command per instance, same
# ones report_and_notify() prints, so an MCP client can actually reach
# RockySurf instead of just learning rockysurf_enabled is True.
rockysurf_access_commands: list[str] | None = None
# Function: resolve_network_and_compute()
# Purpose: phase 1 -- AZ/region validation, instance_type_info, base_os
# checks/family, EBS optimize/encrypt/resize, spot pricing, placement
# group strategy.
def resolve_network_and_compute(
settings: BuildSettings,
aws_clients: AwsClients,
ebs: EbsRequest,
spot_buffer: float,
placement_group_strategy: str,
refer_to_docs_and_quit: QuitFn,
) -> NetworkAndComputeResolution:
ec2_client = aws_clients.ec2_client
debug_mode = settings.debug_mode
validate_az_and_region(ec2_client, settings.az, illegal_az_msg)
p_val("region", debug_mode)
p_val("az", debug_mode)
instance_type_info = get_instance_type_info(ec2_client, settings.instance_type)
if instance_type_info is None:
p_fail(settings.instance_type, "instance_type", "missing_element")
architecture = instance_type_info["architecture"]
print("")
print("Selected EC2 instance type: " + settings.instance_type + " (" + architecture + ")")
print("")
print("Selected base operating system: " + settings.base_os)
base_os_instance_check(settings.base_os, settings.instance_type, architecture, debug_mode)
base_os_family = get_base_os_family(settings.base_os)
is_windows = base_os_family["is_windows"]
package_manager = base_os_family["package_manager"]
awscli_preinstalled = base_os_family["awscli_preinstalled"]
ec2_user = base_os_family["ec2_user"]
ebs_optimized = resolve_ebs_optimized_support(ebs.optimized, settings.instance_type, instance_type_info["ebs_optimized_support"], settings.instance_name)
p_val("ebs_optimized", debug_mode)
if ebs.encryption == "true":
ebs_encryption_check(settings.instance_type, instance_type_info["ebs_encryption_support"], settings.instance_name, debug_mode)
ebs_root_volume_size, ebs_device_volume_size = validate_and_resize_ebs_volumes(
ebs.root_volume_size,
ebs.device_volume_size,
ebs.root_volume_type,
ebs.device_volume_type,
ebs.root_volume_iops,
ebs.device_volume_iops,
is_windows,
settings.base_os,
refer_to_docs_and_quit,
)
# The buffered spot_buffer this returns alongside spot_price was never
# consumed again after this point in the original code either (it's not
# an InstanceParameters field) -- discarded here too, not a behavior
# change.
spot_price, _ = resolve_request_type_pricing(
settings.request_type, ec2_client, settings.instance_type, is_windows, settings.az, spot_buffer, debug_mode, fetch_spot_price_raw, compute_buffered_spot_price, p_val, refer_to_docs_and_quit
)
print("")
placement_group_strategy = resolve_placement_group_strategy(
settings.enable_placement_group,
settings.count,
settings.instance_type,
placement_group_strategy,
instance_type_info,
ec2_placement_group_check,
refer_to_docs_and_quit,
debug_mode,
p_val,
)
return NetworkAndComputeResolution(
architecture=architecture,
is_windows=is_windows,
package_manager=package_manager,
awscli_preinstalled=awscli_preinstalled,
ec2_user=ec2_user,
ebs_optimized=ebs_optimized,
ebs_root_volume_size=ebs_root_volume_size,
ebs_device_volume_size=ebs_device_volume_size,
spot_price=spot_price,
placement_group_strategy=placement_group_strategy,
)
# Function: resolve_vpc_security_and_keypair()
# Purpose: phase 2 -- VPC/subnet, ssh_allowed_ips, security group, ec2_user
# home directory, AMI, keypair.
def resolve_vpc_security_and_keypair(
settings: BuildSettings,
aws_clients: AwsClients,
network: NetworkAndComputeResolution,
vpc_name: str,
security_group: str,
ssh_allowed_ips: str,
custom_ami: str,
ec2_keypair: str,
refer_to_docs_and_quit: QuitFn,
) -> VpcSecurityAndKeypairResolution:
ec2_client = aws_clients.ec2_client
ec2 = aws_clients.ec2
debug_mode = settings.debug_mode
aws_account_id = aws_clients.stsclient.get_caller_identity()["Account"]
vpc_id, vpc_name, subnet_id = resolve_vpc_and_subnet(ec2_client, vpc_name, settings.az, refer_to_docs_and_quit)
p_val("vpc_name", debug_mode)
p_val("subnet_id", debug_mode)
ssh_allowed_ips = resolve_ssh_allowed_ips(ec2_client, vpc_id, ssh_allowed_ips, refer_to_docs_and_quit)
p_val("ssh_allowed_ips", debug_mode)
security_group_name, vpc_security_group_ids, preserve_security_group = resolve_security_group(
ec2, security_group, settings.instance_serial_number, vpc_id, network.is_windows, ssh_allowed_ips, add_inbound_security_group_rule
)
p_val("security_group", debug_mode)
p_val("vpc_security_group_ids", debug_mode)
if preserve_security_group == "true":
print("")
print("*** WARNING ***")
print('Reusing the pre-existing security group "' + security_group_name + '".')
print("Its existing inbound rules are left exactly as they are, so --ssh_allowed_ips")
print("has NO effect on this build, and kill-instance." + settings.instance_name + ".sh will")
print("not delete this security group.")
print("")
ec2_user_home = "/home/" + network.ec2_user
p_val("ec2_user", debug_mode)
p_val("ec2_user_home", debug_mode)
aws_ami = resolve_ami(
custom_ami,
settings.base_os,
network.architecture,
aws_account_id,
functools.partial(get_ami_info, ec2_client),
functools.partial(check_custom_ami, ec2_client),
refer_to_docs_and_quit,
)
p_val("aws_ami", debug_mode)
if ec2_keypair == "ec2_keypair_default":
ec2_keypair = settings.instance_serial_number + "_" + settings.region
secret_key_file = settings.instance_data_dir + ec2_keypair + ".pem"
setup_keypair(ec2_client, ec2_keypair, secret_key_file, settings.region, debug_mode, refer_to_docs_and_quit)
p_val("ec2_keypair", debug_mode)
return VpcSecurityAndKeypairResolution(
aws_account_id=aws_account_id,
vpc_id=vpc_id,
vpc_name=vpc_name,
subnet_id=subnet_id,
ssh_allowed_ips=ssh_allowed_ips,
security_group_name=security_group_name,
vpc_security_group_ids=vpc_security_group_ids,
preserve_security_group=preserve_security_group,
ec2_user_home=ec2_user_home,
aws_ami=aws_ami,
ec2_keypair=ec2_keypair,
)
# Function: provision_iam_sns_and_logging()
# Purpose: phase 3 -- CloudWatch log group, IAM role/policy/profile setup,
# Turbot environment variables, SNS topic creation/subscribe/timestamps.
def provision_iam_sns_and_logging(
settings: BuildSettings,
aws_clients: AwsClients,
aws_account_id: str,
iam_role: str,
iam_json_policy: str,
refer_to_docs_and_quit: QuitFn,
) -> IamSnsAndLoggingResolution:
debug_mode = settings.debug_mode
cloudwatch_log_group = "/ec2instancemaker/" + settings.instance_name
if settings.enable_cloudwatch_logs == "true":
setup_cloudwatch_logging(aws_clients.logs_client, cloudwatch_log_group, settings.log_retention_days, refer_to_docs_and_quit)
ec2_iam_instance_role, ec2_iam_instance_policy, ec2_iam_instance_profile, preserve_iam_role, rockysurf_boundary_policy = setup_iam(
aws_clients.iam,
iam_role,
settings.iam_name_prefix,
iam_json_policy,
settings.instance_data_dir,
settings.instance_serial_number,
aws_account_id,
settings.enable_rockysurf,
debug_mode,
refer_to_docs_and_quit,
modify_iam_policy_document,
)
if debug_mode == "true":
print("")
p_val("ec2_iam_instance_role", debug_mode)
p_val("ec2_iam_instance_profile", debug_mode)
if settings.turbot_account != "DISABLED":
turbot_profile = "turbot__" + settings.turbot_account + "__" + settings.instance_owner
os.environ["AWS_PROFILE"] = turbot_profile
os.environ["AWS_DEFAULT_REGION"] = settings.region
boto3.setup_default_session(profile_name=turbot_profile)
sns_topic_name, sns_topic_arn = create_sns_topic_and_subscribe(aws_clients.sns_client, settings.instance_serial_number, settings.instance_owner_email)
if debug_mode == "true":
print("")
print("Subscribed " + settings.instance_owner_email + " to SNS topic: " + sns_topic_name)
print("")
p_val("sns_topic_name", debug_mode)
sns_datestamp, sns_timestamp = generate_sns_timestamps()
return IamSnsAndLoggingResolution(
cloudwatch_log_group=cloudwatch_log_group,
ec2_iam_instance_role=ec2_iam_instance_role,
ec2_iam_instance_policy=ec2_iam_instance_policy,
ec2_iam_instance_profile=ec2_iam_instance_profile,
preserve_iam_role=preserve_iam_role,
rockysurf_boundary_policy=rockysurf_boundary_policy,
sns_topic_arn=sns_topic_arn,
sns_datestamp=sns_datestamp,
sns_timestamp=sns_timestamp,
)
# Function: render_and_apply()
# Purpose: phase 4 -- assemble InstanceParameters, print the --debug_mode
# dump, write the vars_file, render the Jinja2 templates, run the
# CTRL-C-abort safety window, apply Terraform, and tag the security group.
# ctrlc_abort_seconds overrides the window's length (default: computed from
# debug_mode, same as always) -- mcp_server.py's build_instance tool passes
# 0, since there's no human at a terminal to type CTRL-C in the first place;
# real safety there comes from the tool's own required confirm=True
# argument instead.
def render_and_apply(
settings: BuildSettings,
aws_clients: AwsClients,
network: NetworkAndComputeResolution,
vpc: VpcSecurityAndKeypairResolution,
iam_sns: IamSnsAndLoggingResolution,
options: BuildOptions,
ebs: EbsRequest,
cwd: str,
instance_data_dir_abs: str,
custom_user_prelogin_scripts: list[str],
custom_user_postboot_scripts: list[str],
refer_to_docs_and_quit: QuitFn,
ctrlc_abort_seconds: int | None = None,
) -> None:
debug_mode = settings.debug_mode
instance_parameters = InstanceParameters(
architecture=network.architecture,
awscli_preinstalled=network.awscli_preinstalled,
az=settings.az,
aws_ami=vpc.aws_ami,
aws_account_id=vpc.aws_account_id,
base_os=settings.base_os,
is_windows=network.is_windows,
package_manager=network.package_manager,
count=settings.count,
custom_user_prelogin_scripts=custom_user_prelogin_scripts,
custom_user_postboot_scripts=custom_user_postboot_scripts,
debug_mode=debug_mode,
ebs_encryption=ebs.encryption,
ebs_optimized=network.ebs_optimized,
ebs_root_volume_size=network.ebs_root_volume_size,
ebs_root_volume_type=ebs.root_volume_type,
ebs_root_volume_iops=ebs.root_volume_iops,
ebs_device_volume_size=network.ebs_device_volume_size,
ebs_device_volume_type=ebs.device_volume_type,
ebs_device_volume_iops=ebs.device_volume_iops,
instance_type=settings.instance_type,
ec2_keypair=vpc.ec2_keypair,
ec2_user=network.ec2_user,
ec2_user_home=vpc.ec2_user_home,
ec2_iam_instance_policy=iam_sns.ec2_iam_instance_policy,
ec2_iam_instance_profile=iam_sns.ec2_iam_instance_profile,
ec2_iam_instance_role=iam_sns.ec2_iam_instance_role,
rockysurf_boundary_policy=iam_sns.rockysurf_boundary_policy,
enable_placement_group=settings.enable_placement_group,
hyperthreading=options.hyperthreading,
iam_name_prefix=settings.iam_name_prefix,
instance_data_dir=instance_data_dir_abs,
instance_owner=settings.instance_owner,
instance_owner_email=settings.instance_owner_email,
instance_owner_department=settings.instance_owner_department,
instance_name=settings.instance_name,
request_type=settings.request_type,
instance_serial_number=settings.instance_serial_number,
instance_serial_number_file=settings.instance_serial_number_file,
cloudwatch_log_group=iam_sns.cloudwatch_log_group,
enable_cloudwatch_logs=settings.enable_cloudwatch_logs,
enable_rockysurf=settings.enable_rockysurf,
log_retention_days=settings.log_retention_days,
placement_group_strategy=network.placement_group_strategy,
preserve_ami=options.preserve_ami,
preserve_cloudwatch_logs=options.preserve_cloudwatch_logs,
prod_level=options.prod_level,
project_id=options.project_id,
preserve_iam_role=iam_sns.preserve_iam_role,
preserve_security_group=vpc.preserve_security_group,
public_ip=options.public_ip,
region=settings.region,
security_group_name=vpc.security_group_name,
spot_price=network.spot_price,
ssh_allowed_ips=vpc.ssh_allowed_ips,
vpc_security_group_ids=vpc.vpc_security_group_ids,
sns_topic_arn=iam_sns.sns_topic_arn,
sns_datestamp=iam_sns.sns_datestamp,
sns_timestamp=iam_sns.sns_timestamp,
subnet_id=vpc.subnet_id,
turbot_account=settings.turbot_account,
vars_file_path=settings.vars_file_path,
vpc_id=vpc.vpc_id,
vpc_name=vpc.vpc_name,
DEPLOYMENT_DATE=settings.DEPLOYMENT_DATE,
DEPLOYMENT_DATE_TAG=settings.DEPLOYMENT_DATE_TAG,
TERRAFORM_VERSION=settings.TERRAFORM_VERSION,
)
if debug_mode == "true":
print_debug_parameters(instance_parameters)
vars_file_main_part = """\
################################################################################
# Name: {instance_name}.yml
# Author: Rodney Marable <rodney.marable@gmail.com>
# Created On: June 3, 2019
# Last Changed: July 17, 2019
# Deployed On: {DEPLOYMENT_DATE}
# Purpose: Build template auto-generated by Ec2InstanceMaker
################################################################################
# Build tool information
debug_mode: {debug_mode}
vars_file_path: {vars_file_path}
DEPLOYMENT_DATE: {DEPLOYMENT_DATE}
DEPLOYMENT_DATE_TAG: {DEPLOYMENT_DATE_TAG}
# SNS topic
sns_arn: {sns_topic_arn}
# IAM parameters
ec2_iam_instance_policy: {ec2_iam_instance_policy}
ec2_iam_instance_profile: {ec2_iam_instance_profile}
ec2_iam_instance_role: {ec2_iam_instance_role}
preserve_iam_role: {preserve_iam_role}
preserve_security_group: {preserve_security_group}
rockysurf_boundary_policy: {rockysurf_boundary_policy}
# EC2 instance parameters
aws_ami: {aws_ami}
preserve_ami: {preserve_ami}
cloudwatch_log_group: {cloudwatch_log_group}
enable_cloudwatch_logs: {enable_cloudwatch_logs}
log_retention_days: {log_retention_days}
preserve_cloudwatch_logs: {preserve_cloudwatch_logs}
enable_rockysurf: {enable_rockysurf}
base_os: {base_os}
count: {count}
instance_type: {instance_type}
architecture: {architecture}
ec2_keypair: {ec2_keypair}
ec2_user: {ec2_user}
ec2_user_home: /home/{ec2_user}
ec2_user_src: {ec2_user_home}/src
custom_user_prelogin_scripts: {custom_user_prelogin_scripts}
custom_user_postboot_scripts: {custom_user_postboot_scripts}
hyperthreading: {hyperthreading}
instance_data_dir: {instance_data_dir}
instance_userdata_script: instance_userdata.{instance_name}.sh
instance_name: {instance_name}
instance_owner: {instance_owner}
instance_owner_department: {instance_owner_department}
instance_owner_email: {instance_owner_email}
request_type: {request_type}
instance_serial_number: {instance_serial_number}
instance_serial_number_file: {instance_serial_number_file}
prod_level: {prod_level}
project_id: {project_id}
spot_price: {spot_price}
ssh_keypair_file: {ec2_keypair}.pem
ssh_known_hosts: ~/.ssh/known_hosts
# EBS parameters
ebs_encryption: {ebs_encryption}
ebs_optimized: {ebs_optimized}
ebs_root_volume_size: {ebs_root_volume_size}
ebs_root_volume_type: {ebs_root_volume_type}
ebs_root_volume_iops: {ebs_root_volume_iops}
ebs_device_volume_size: {ebs_device_volume_size}
ebs_device_volume_type: {ebs_device_volume_type}
ebs_device_volume_iops: {ebs_device_volume_iops}
# AWS networking
az: {az}
enable_placement_group: {enable_placement_group}
placement_group_strategy: {placement_group_strategy}
public_ip: {public_ip}
region: {region}
security_group_name: {security_group_name}
ssh_allowed_ips: {ssh_allowed_ips}
subnet_id: {subnet_id}
vpc_id: {vpc_id}
vpc_name: {vpc_name}
vpc_security_group_ids: {vpc_security_group_ids}
# Terraform
terraform_version: {TERRAFORM_VERSION}
provider: aws.{vpc_name}
provider_tf_dest: provider_aws.tf
tf_ec2_instance_dest: {instance_name}.tf
# Generated file names (all written into instance_data_dir above)
access_instance_dest: access_instance.{instance_name}.py
build_instance_script: build_instance.{instance_name}.sh
build_ami_script: build_ami.{instance_name}.sh
kill_instance_script: kill_instance.{instance_name}.sh
"""
write_vars_file(settings.vars_file_path, vars_file_main_part, dataclasses.asdict(instance_parameters))
print("")
print("Saved " + settings.instance_name + " build template: " + settings.vars_file_path)
print("")
if settings.count == 1:
print("Generating templates for instance " + settings.instance_name + "...")
else:
print("Generating templates for instance family " + settings.instance_name + "...")
render_instance_templates(dataclasses.asdict(instance_parameters), cwd, instance_data_dir_abs)
with open(settings.instance_serial_number_file, "a") as fh:
print("Rendered instance templates into: " + instance_data_dir_abs, file=fh)
ctrlC_Abort(
ctrlc_abort_seconds if ctrlc_abort_seconds is not None else (30 if debug_mode == "true" else 5),
80,
settings.vars_file_path,
settings.instance_data_dir,
settings.instance_serial_number_file,
aws_clients.ec2_client,
aws_clients.iam,
vpc.security_group_name,