Coverage for cli / ephemeral_bastion.py: 100.00%

260 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Ephemeral SSM bastion lifecycle for reaching a private EKS API endpoint. 

2 

3``gco monitoring open --via-ssm <instance-id>`` tunnels to a private EKS 

4endpoint through an *existing* SSM-managed instance. This module lets the CLI 

5**create that instance on demand** — a minimal, self-terminating ``t3.micro`` 

6(falling back through equivalent burstable types when a Region or AZ can't 

7launch one) in the cluster VPC — and tear it down when the port-forward 

8session ends, so an operator doesn't have to keep a standing bastion around 

9just to view Grafana. 

10 

11Orphan safeguards (defence in depth — a crash, ``Ctrl-C``, or a forgotten 

12teardown must never leave a paid instance running): 

13 

14* launched with ``--instance-initiated-shutdown-behavior terminate``; 

15* user-data schedules ``shutdown -h +<ttl>`` as an unconditional backstop; 

16* IMDSv2 required (``HttpTokens=required``); 

17* tagged ``gco:ephemeral=true`` + ``gco:purpose`` so any orphan is greppable; 

18* the CLI tears it down in a ``finally:`` block. 

19 

20Network posture: the instance reuses the cluster's own security group (which is 

21self-referencing, so it can reach the private API endpoint) and is placed in one 

22of the cluster's private subnets **without a public IP**. GCO private subnets 

23have NAT egress, which lets the SSM agent reach Systems Manager over HTTPS while 

24keeping the instance unreachable from the internet. No inbound ports are opened; 

25SSM is agent-initiated outbound only. 

26 

27Style matches :mod:`cli.ssm_tunnel`: pure, validated argv builders (list form, 

28never a shell string) that are fully unit-testable, plus thin runtime wrappers 

29that shell out to the AWS CLI (the ``cli`` package does not depend on boto3). 

30""" 

31 

32from __future__ import annotations 

33 

34import json 

35import logging 

36import re 

37import subprocess 

38import time 

39from collections.abc import Iterator 

40from contextlib import contextmanager 

41from dataclasses import dataclass 

42 

43from ._image_uri import aws_partition, aws_url_suffix 

44 

45logger = logging.getLogger(__name__) 

46 

47# -------------------------------------------------------------------------- 

48# Constants — the fixed identity of the ephemeral bastion. 

49# -------------------------------------------------------------------------- 

50 

51# The bastion's IAM role, instance profile, and Name tag are scoped to the 

52# deployment's project key (cdk.json context.project_name, default "gco" — the 

53# same key cli/config.py derives cluster and stack names from), so a non-default 

54# deployment addresses its own resources and two differently named deployments in 

55# one account don't collide on a shared name. See bastion_role_name() below. 

56DEFAULT_PROJECT_NAME = "gco" 

57_ROLE_NAME_SUFFIX = "-ephemeral-bastion-role" 

58_PROFILE_NAME_SUFFIX = "-ephemeral-bastion-profile" 

59_INSTANCE_NAME_SUFFIX = "-ephemeral-ssm-bastion" 

60 

61 

62# AmazonSSMManagedInstanceCore is AWS-managed and therefore not project-scoped, 

63# but its ARN partition must follow the deployment Region. 

64def ssm_managed_policy_arn(region: str) -> str: 

65 return f"arn:{aws_partition(region)}:iam::aws:policy/AmazonSSMManagedInstanceCore" 

66 

67 

68def bastion_trust_policy(region: str) -> dict[str, object]: 

69 """Return an EC2 trust policy using the partition's service DNS suffix.""" 

70 return { 

71 "Version": "2012-10-17", 

72 "Statement": [ 

73 { 

74 "Effect": "Allow", 

75 "Principal": {"Service": f"ec2.{aws_url_suffix(region)}"}, 

76 "Action": "sts:AssumeRole", 

77 } 

78 ], 

79 } 

80 

81 

82# Back-compatible commercial defaults for callers that imported the constants. 

83SSM_MANAGED_POLICY_ARN = ssm_managed_policy_arn("us-east-1") 

84 

85BASTION_INSTANCE_TYPE = "t3.micro" 

86 

87# Fallback chain when the preferred type cannot be launched in the target 

88# subnet — a Region/AZ that lacks the type entirely, or transient 

89# InsufficientInstanceCapacity. Ordered cheapest-first and all x86_64 (the 

90# resolved AL2023 AMI is x86_64, so an arm64 type can never be substituted 

91# without also changing the AMI). The bastion only forwards an SSM tunnel, so 

92# any burstable nano/micro/small class is dimensionally identical for the job. 

93BASTION_INSTANCE_TYPE_FALLBACKS: tuple[str, ...] = ( 

94 "t3a.micro", 

95 "t3.small", 

96 "t2.micro", 

97) 

98 

99# EC2 error markers that mean "this instance type won't launch here" — the 

100# cue to try the next type rather than fail the tunnel. Anything else 

101# (auth, quota on vCPUs, malformed request) still raises immediately. 

102_INSTANCE_TYPE_UNAVAILABLE_MARKERS = ( 

103 "InsufficientInstanceCapacity", 

104 "Unsupported", 

105 "InstanceTypeNotSupported", 

106 "unsupported instance type", 

107 "not supported in your requested Availability Zone", 

108) 

109 

110# Public SSM parameter that always resolves to the latest Amazon Linux 2023 

111# x86_64 AMI in the target region (t3.micro is x86_64). 

112AL2023_AMI_SSM_PARAMETER = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" 

113 

114# Tags stamped on every ephemeral instance so orphans are trivially discoverable: 

115# aws ec2 describe-instances \ 

116# --filters Name=tag:gco:ephemeral,Values=true \ 

117# Name=instance-state-name,Values=running,pending 

118TAG_EPHEMERAL_KEY = "gco:ephemeral" 

119TAG_PURPOSE_KEY = "gco:purpose" 

120TAG_PROJECT_KEY = "gco:project" 

121TAG_TTL_KEY = "gco:ttl-minutes" 

122BASTION_PURPOSE = "cluster-observability" 

123 

124DEFAULT_TTL_MINUTES = 120 

125# The self-termination backstop a caller may ask for (five minutes to one day). 

126MIN_TTL_MINUTES = 5 

127MAX_TTL_MINUTES = 1440 

128 

129# EC2 instance-profile propagation to the RunInstances API is eventually 

130# consistent; retry the launch for a short window on the "not found" error. 

131_PROFILE_PROPAGATION_RETRIES = 6 

132_PROFILE_PROPAGATION_WAIT_SECONDS = 5.0 

133 

134# EC2 trust policy default; builders derive the principal from their target Region. 

135BASTION_TRUST_POLICY: dict[str, object] = bastion_trust_policy("us-east-1") 

136 

137# -------------------------------------------------------------------------- 

138# Validators — every AWS-supplied id is re-validated before it enters an argv. 

139# -------------------------------------------------------------------------- 

140 

141_REGION_RE = re.compile(r"^[a-z]{2,4}(?:-[a-z0-9]+)+-[0-9]+$") 

142_CLUSTER_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9\-]{0,99}$") 

143_INSTANCE_RE = re.compile(r"^i-[0-9a-f]{8}([0-9a-f]{9})?$") 

144_VPC_RE = re.compile(r"^vpc-[0-9a-f]{8}([0-9a-f]{9})?$") 

145_SUBNET_RE = re.compile(r"^subnet-[0-9a-f]{8}([0-9a-f]{9})?$") 

146_SG_RE = re.compile(r"^sg-[0-9a-f]{8}([0-9a-f]{9})?$") 

147_AMI_RE = re.compile(r"^ami-[0-9a-f]{8}([0-9a-f]{9})?$") 

148_INSTANCE_TYPE_RE = re.compile(r"^[a-z0-9]+\.[a-z0-9]+$") 

149 

150 

151def _validate(value: str, pattern: re.Pattern[str], what: str) -> str: 

152 if not isinstance(value, str) or not pattern.match(value): 

153 raise ValueError(f"Invalid {what}: {value!r}") 

154 return value 

155 

156 

157def _validate_ttl(ttl_minutes: int) -> int: 

158 try: 

159 value = int(ttl_minutes) 

160 except (TypeError, ValueError) as exc: 

161 raise ValueError(f"Invalid ttl-minutes {ttl_minutes!r}: must be an integer") from exc 

162 if not MIN_TTL_MINUTES <= value <= MAX_TTL_MINUTES: 

163 raise ValueError( 

164 f"Invalid ttl-minutes {value}: must be between {MIN_TTL_MINUTES} and {MAX_TTL_MINUTES}" 

165 ) 

166 return value 

167 

168 

169# Project keys follow the cdk.json context.project_name shape (alnum + hyphen). 

170_PROJECT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9\-]{0,40}$") 

171 

172 

173def _validate_project(project_name: str) -> str: 

174 return _validate(project_name, _PROJECT_RE, "project name") 

175 

176 

177def bastion_role_name(project_name: str = DEFAULT_PROJECT_NAME) -> str: 

178 """IAM role name for ``project_name``'s ephemeral bastion.""" 

179 return f"{_validate_project(project_name)}{_ROLE_NAME_SUFFIX}" 

180 

181 

182def bastion_profile_name(project_name: str = DEFAULT_PROJECT_NAME) -> str: 

183 """Instance-profile name for ``project_name``'s ephemeral bastion.""" 

184 return f"{_validate_project(project_name)}{_PROFILE_NAME_SUFFIX}" 

185 

186 

187def bastion_instance_name(project_name: str = DEFAULT_PROJECT_NAME) -> str: 

188 """EC2 ``Name`` tag for ``project_name``'s ephemeral bastion.""" 

189 return f"{_validate_project(project_name)}{_INSTANCE_NAME_SUFFIX}" 

190 

191 

192# Back-compat convenience constants for the default project. The helpers above 

193# are the source of truth; these are the default-project values that the builder 

194# defaults (and tests) reference. 

195BASTION_ROLE_NAME = bastion_role_name() 

196BASTION_PROFILE_NAME = bastion_profile_name() 

197BASTION_NAME = bastion_instance_name() 

198 

199 

200@dataclass(frozen=True) 

201class BastionNetwork: 

202 """Private VPC placement for an ephemeral bastion.""" 

203 

204 vpc_id: str 

205 subnet_id: str 

206 security_group_id: str 

207 

208 

209# -------------------------------------------------------------------------- 

210# Pure argv / payload builders (unit-tested; list form, no shell). 

211# -------------------------------------------------------------------------- 

212 

213 

214def render_user_data(ttl_minutes: int = DEFAULT_TTL_MINUTES) -> str: 

215 """Return the bastion boot script: an unconditional self-terminate backstop.""" 

216 ttl = _validate_ttl(ttl_minutes) 

217 return ( 

218 "#!/bin/bash\n" 

219 "# GCO ephemeral SSM bastion — self-terminate backstop.\n" 

220 "# The instance is launched with --instance-initiated-shutdown-behavior\n" 

221 "# terminate, so this scheduled halt terminates it even if no explicit\n" 

222 "# teardown ever runs.\n" 

223 f'shutdown -h +{ttl} "gco ephemeral bastion self-terminate backstop"\n' 

224 ) 

225 

226 

227def build_get_ami_command(region: str) -> list[str]: 

228 """``aws ssm get-parameter`` argv resolving the latest AL2023 x86_64 AMI.""" 

229 _validate(region, _REGION_RE, "region") 

230 return [ 

231 "aws", 

232 "ssm", 

233 "get-parameter", 

234 "--name", 

235 AL2023_AMI_SSM_PARAMETER, 

236 "--region", 

237 region, 

238 "--query", 

239 "Parameter.Value", 

240 "--output", 

241 "text", 

242 ] 

243 

244 

245def build_describe_cluster_network_command(cluster: str, region: str) -> list[str]: 

246 """``aws eks describe-cluster`` argv projecting VPC, cluster SG, and subnets.""" 

247 _validate(cluster, _CLUSTER_RE, "cluster name") 

248 _validate(region, _REGION_RE, "region") 

249 return [ 

250 "aws", 

251 "eks", 

252 "describe-cluster", 

253 "--name", 

254 cluster, 

255 "--region", 

256 region, 

257 "--query", 

258 ("cluster.resourcesVpcConfig.{vpc:vpcId,sg:clusterSecurityGroupId,subnets:subnetIds}"), 

259 "--output", 

260 "json", 

261 ] 

262 

263 

264def build_describe_private_cluster_subnet_command(subnet_ids: list[str], region: str) -> list[str]: 

265 """Find the first private subnet among the EKS control-plane subnets.""" 

266 if not subnet_ids: 

267 raise ValueError("At least one cluster subnet id is required") 

268 validated_subnets = [ 

269 _validate(subnet_id, _SUBNET_RE, "cluster subnet id") for subnet_id in subnet_ids 

270 ] 

271 _validate(region, _REGION_RE, "region") 

272 return [ 

273 "aws", 

274 "ec2", 

275 "describe-subnets", 

276 "--region", 

277 region, 

278 "--subnet-ids", 

279 *validated_subnets, 

280 "--filters", 

281 "Name=map-public-ip-on-launch,Values=false", 

282 "--query", 

283 "Subnets[0].SubnetId", 

284 "--output", 

285 "text", 

286 ] 

287 

288 

289def build_create_role_command( 

290 role_name: str = BASTION_ROLE_NAME, 

291 region: str = "us-east-1", 

292) -> list[str]: 

293 """``aws iam create-role`` argv with the partition-correct EC2 trust.""" 

294 _validate(role_name, _CLUSTER_RE, "role name") 

295 _validate(region, _REGION_RE, "region") 

296 return [ 

297 "aws", 

298 "iam", 

299 "create-role", 

300 "--region", 

301 region, 

302 "--role-name", 

303 role_name, 

304 "--assume-role-policy-document", 

305 json.dumps(bastion_trust_policy(region)), 

306 "--description", 

307 "GCO ephemeral SSM bastion (cluster-observability); safe to delete.", 

308 "--tags", 

309 f"Key={TAG_EPHEMERAL_KEY},Value=true", 

310 f"Key={TAG_PURPOSE_KEY},Value={BASTION_PURPOSE}", 

311 ] 

312 

313 

314def build_attach_role_policy_command( 

315 role_name: str = BASTION_ROLE_NAME, 

316 region: str = "us-east-1", 

317) -> list[str]: 

318 """``aws iam attach-role-policy`` argv attaching the partition policy.""" 

319 _validate(role_name, _CLUSTER_RE, "role name") 

320 _validate(region, _REGION_RE, "region") 

321 return [ 

322 "aws", 

323 "iam", 

324 "attach-role-policy", 

325 "--region", 

326 region, 

327 "--role-name", 

328 role_name, 

329 "--policy-arn", 

330 ssm_managed_policy_arn(region), 

331 ] 

332 

333 

334def build_create_instance_profile_command( 

335 profile_name: str = BASTION_PROFILE_NAME, 

336 region: str = "us-east-1", 

337) -> list[str]: 

338 """``aws iam create-instance-profile`` argv.""" 

339 _validate(profile_name, _CLUSTER_RE, "instance-profile name") 

340 _validate(region, _REGION_RE, "region") 

341 return [ 

342 "aws", 

343 "iam", 

344 "create-instance-profile", 

345 "--region", 

346 region, 

347 "--instance-profile-name", 

348 profile_name, 

349 ] 

350 

351 

352def build_add_role_to_profile_command( 

353 role_name: str = BASTION_ROLE_NAME, 

354 profile_name: str = BASTION_PROFILE_NAME, 

355 region: str = "us-east-1", 

356) -> list[str]: 

357 """``aws iam add-role-to-instance-profile`` argv.""" 

358 _validate(role_name, _CLUSTER_RE, "role name") 

359 _validate(profile_name, _CLUSTER_RE, "instance-profile name") 

360 _validate(region, _REGION_RE, "region") 

361 return [ 

362 "aws", 

363 "iam", 

364 "add-role-to-instance-profile", 

365 "--region", 

366 region, 

367 "--instance-profile-name", 

368 profile_name, 

369 "--role-name", 

370 role_name, 

371 ] 

372 

373 

374def _tag_specification( 

375 ttl_minutes: int, 

376 instance_name: str = BASTION_NAME, 

377 project_name: str = DEFAULT_PROJECT_NAME, 

378) -> str: 

379 """Build the ``--tag-specifications`` value stamping the ephemeral markers.""" 

380 project = _validate_project(project_name) 

381 tags = ( 

382 f"{{Key=Name,Value={instance_name}}}," 

383 f"{{Key={TAG_EPHEMERAL_KEY},Value=true}}," 

384 f"{{Key={TAG_PURPOSE_KEY},Value={BASTION_PURPOSE}}}," 

385 f"{{Key={TAG_PROJECT_KEY},Value={project}}}," 

386 f"{{Key={TAG_TTL_KEY},Value={ttl_minutes}}}" 

387 ) 

388 return f"ResourceType=instance,Tags=[{tags}]" 

389 

390 

391def build_run_instances_command( 

392 *, 

393 ami_id: str, 

394 instance_type: str, 

395 subnet_id: str, 

396 security_group_id: str, 

397 profile_name: str, 

398 region: str, 

399 user_data: str, 

400 ttl_minutes: int = DEFAULT_TTL_MINUTES, 

401 instance_name: str = BASTION_NAME, 

402 project_name: str = DEFAULT_PROJECT_NAME, 

403) -> list[str]: 

404 """Build the validated ``aws ec2 run-instances`` argv, safeguards included. 

405 

406 The safeguards (IMDSv2, shutdown-behaviour terminate, TTL user-data, and the 

407 ``gco:ephemeral`` tag set) are not optional — they are the contract that 

408 keeps a forgotten teardown from turning into a paid orphan. 

409 """ 

410 _validate(ami_id, _AMI_RE, "AMI id") 

411 _validate(subnet_id, _SUBNET_RE, "subnet id") 

412 _validate(security_group_id, _SG_RE, "security group id") 

413 _validate(profile_name, _CLUSTER_RE, "instance-profile name") 

414 _validate(region, _REGION_RE, "region") 

415 ttl = _validate_ttl(ttl_minutes) 

416 if not _INSTANCE_TYPE_RE.match(instance_type): 

417 raise ValueError(f"Invalid instance type {instance_type!r}") 

418 

419 cmd = [ 

420 "aws", 

421 "ec2", 

422 "run-instances", 

423 "--region", 

424 region, 

425 "--image-id", 

426 ami_id, 

427 "--instance-type", 

428 instance_type, 

429 "--count", 

430 "1", 

431 "--subnet-id", 

432 subnet_id, 

433 "--security-group-ids", 

434 security_group_id, 

435 "--iam-instance-profile", 

436 f"Name={profile_name}", 

437 # IMDSv2 required. 

438 "--metadata-options", 

439 "HttpTokens=required,HttpEndpoint=enabled", 

440 # Orphan safeguard #1: an OS-initiated shutdown terminates the instance. 

441 "--instance-initiated-shutdown-behavior", 

442 "terminate", 

443 # Orphan safeguard #2: schedule that shutdown from boot. 

444 "--user-data", 

445 user_data, 

446 # Orphan safeguard #3: greppable ephemeral tags. 

447 "--tag-specifications", 

448 _tag_specification(ttl, instance_name, project_name), 

449 ] 

450 cmd.append("--no-associate-public-ip-address") 

451 # Ask only for the instance id back. 

452 cmd += ["--query", "Instances[0].InstanceId", "--output", "text"] 

453 return cmd 

454 

455 

456def build_describe_ssm_ping_command(instance_id: str, region: str) -> list[str]: 

457 """``aws ssm describe-instance-information`` argv projecting the PingStatus.""" 

458 _validate(instance_id, _INSTANCE_RE, "instance id") 

459 _validate(region, _REGION_RE, "region") 

460 return [ 

461 "aws", 

462 "ssm", 

463 "describe-instance-information", 

464 "--region", 

465 region, 

466 "--filters", 

467 f"Key=InstanceIds,Values={instance_id}", 

468 "--query", 

469 "InstanceInformationList[0].PingStatus", 

470 "--output", 

471 "text", 

472 ] 

473 

474 

475def build_terminate_instances_command(instance_id: str, region: str) -> list[str]: 

476 """``aws ec2 terminate-instances`` argv.""" 

477 _validate(instance_id, _INSTANCE_RE, "instance id") 

478 _validate(region, _REGION_RE, "region") 

479 return [ 

480 "aws", 

481 "ec2", 

482 "terminate-instances", 

483 "--region", 

484 region, 

485 "--instance-ids", 

486 instance_id, 

487 "--query", 

488 "TerminatingInstances[0].CurrentState.Name", 

489 "--output", 

490 "text", 

491 ] 

492 

493 

494def build_iam_teardown_commands( 

495 role_name: str = BASTION_ROLE_NAME, 

496 profile_name: str = BASTION_PROFILE_NAME, 

497 region: str = "us-east-1", 

498) -> list[list[str]]: 

499 """Return ordered, partition-aware IAM teardown argvs.""" 

500 _validate(role_name, _CLUSTER_RE, "role name") 

501 _validate(profile_name, _CLUSTER_RE, "instance-profile name") 

502 _validate(region, _REGION_RE, "region") 

503 policy_arn = ssm_managed_policy_arn(region) 

504 region_args = ["--region", region] 

505 return [ 

506 [ 

507 "aws", 

508 "iam", 

509 "remove-role-from-instance-profile", 

510 *region_args, 

511 "--instance-profile-name", 

512 profile_name, 

513 "--role-name", 

514 role_name, 

515 ], 

516 [ 

517 "aws", 

518 "iam", 

519 "delete-instance-profile", 

520 *region_args, 

521 "--instance-profile-name", 

522 profile_name, 

523 ], 

524 [ 

525 "aws", 

526 "iam", 

527 "detach-role-policy", 

528 *region_args, 

529 "--role-name", 

530 role_name, 

531 "--policy-arn", 

532 policy_arn, 

533 ], 

534 ["aws", "iam", "delete-role", *region_args, "--role-name", role_name], 

535 ] 

536 

537 

538# -------------------------------------------------------------------------- 

539# Pure parsers (unit-tested). 

540# -------------------------------------------------------------------------- 

541 

542 

543def parse_cluster_network(stdout: str) -> tuple[str, str, list[str]]: 

544 """Parse ``build_describe_cluster_network_command`` JSON → (vpc, sg, subnets).""" 

545 data = json.loads(stdout or "{}") 

546 vpc = data.get("vpc") or "" 

547 sg = data.get("sg") or "" 

548 subnets = data.get("subnets") or [] 

549 if not vpc or not sg: 

550 raise RuntimeError( 

551 "Cluster VPC or cluster security group not found in describe-cluster output; " 

552 "cannot place an ephemeral bastion." 

553 ) 

554 return vpc, sg, list(subnets) 

555 

556 

557def _clean_scalar(stdout: str) -> str: 

558 """Normalise an ``--output text`` scalar (strip; treat 'None' as empty).""" 

559 value = (stdout or "").strip() 

560 return "" if value in ("", "None") else value 

561 

562 

563# -------------------------------------------------------------------------- 

564# Runtime wrappers (thin; shell out to the AWS CLI). 

565# -------------------------------------------------------------------------- 

566 

567 

568def _run_aws(cmd: list[str], *, allow_exists: bool = False) -> str: 

569 """Run an AWS CLI argv, returning stdout. Raise ``RuntimeError`` on failure. 

570 

571 ``allow_exists`` swallows idempotent "already exists" IAM errors so repeated 

572 runs reuse the standing role/profile instead of failing. 

573 """ 

574 try: 

575 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - argv built by validated builders; list form, no shell=True 

576 cmd, 

577 capture_output=True, 

578 text=True, 

579 check=False, 

580 ) 

581 except FileNotFoundError as exc: 

582 raise RuntimeError( 

583 "AWS CLI not found. Please install the AWS CLI and ensure it's in your PATH." 

584 ) from exc 

585 if result.returncode != 0: 

586 stderr = result.stderr or "" 

587 if allow_exists and ( 

588 "EntityAlreadyExists" in stderr 

589 or "already exists" in stderr 

590 # IAM reports an idempotent add-role-to-instance-profile call as 

591 # this quota error when the profile already contains its one role. 

592 or "Cannot exceed quota for InstanceSessionsPerInstanceProfile" in stderr 

593 ): 

594 return result.stdout or "" 

595 raise RuntimeError(f"AWS CLI command failed ({' '.join(cmd[:3])}): {stderr.strip()}") 

596 return result.stdout or "" 

597 

598 

599def resolve_bastion_ami(region: str) -> str: 

600 """Resolve the latest AL2023 x86_64 AMI id for ``region``.""" 

601 ami = _clean_scalar(_run_aws(build_get_ami_command(region))) 

602 return _validate(ami, _AMI_RE, "resolved AMI id") 

603 

604 

605def resolve_bastion_network(cluster: str, region: str) -> BastionNetwork: 

606 """Discover private VPC placement for the bastion. 

607 

608 GCO gives EKS private control-plane subnets with NAT egress. We select one 

609 of those subnets and refuse a public-subnet fallback, ensuring the bastion 

610 never receives a public IP. A deployment without NAT can provide Systems 

611 Manager interface VPC endpoints instead. 

612 """ 

613 vpc, sg, subnets = parse_cluster_network( 

614 _run_aws(build_describe_cluster_network_command(cluster, region)) 

615 ) 

616 _validate(vpc, _VPC_RE, "cluster vpc id") 

617 _validate(sg, _SG_RE, "cluster security group id") 

618 

619 if not subnets: 

620 raise RuntimeError( 

621 f"No cluster subnets found in VPC {vpc}; cannot place a private bastion." 

622 ) 

623 private_subnet = _clean_scalar( 

624 _run_aws(build_describe_private_cluster_subnet_command(list(subnets), region)) 

625 ) 

626 if not private_subnet: 

627 raise RuntimeError( 

628 f"No private EKS subnet found in VPC {vpc}; refusing to launch a public bastion." 

629 ) 

630 return BastionNetwork( 

631 vpc, 

632 _validate(private_subnet, _SUBNET_RE, "private cluster subnet id"), 

633 sg, 

634 ) 

635 

636 

637def ensure_bastion_iam( 

638 project_name: str = DEFAULT_PROJECT_NAME, 

639 region: str = "us-east-1", 

640) -> None: 

641 """Idempotently create the bastion role + profile in one partition.""" 

642 role_name = bastion_role_name(project_name) 

643 profile_name = bastion_profile_name(project_name) 

644 _run_aws(build_create_role_command(role_name, region), allow_exists=True) 

645 _run_aws(build_attach_role_policy_command(role_name, region), allow_exists=True) 

646 _run_aws(build_create_instance_profile_command(profile_name, region), allow_exists=True) 

647 _run_aws( 

648 build_add_role_to_profile_command(role_name, profile_name, region), 

649 allow_exists=True, 

650 ) 

651 

652 

653def launch_bastion( 

654 *, 

655 network: BastionNetwork, 

656 ami_id: str, 

657 region: str, 

658 ttl_minutes: int, 

659 project_name: str = DEFAULT_PROJECT_NAME, 

660 instance_type: str = BASTION_INSTANCE_TYPE, 

661) -> str: 

662 """Run the instance, retrying briefly while the instance profile propagates.""" 

663 cmd = build_run_instances_command( 

664 ami_id=ami_id, 

665 instance_type=instance_type, 

666 subnet_id=network.subnet_id, 

667 security_group_id=network.security_group_id, 

668 profile_name=bastion_profile_name(project_name), 

669 region=region, 

670 user_data=render_user_data(ttl_minutes), 

671 ttl_minutes=ttl_minutes, 

672 instance_name=bastion_instance_name(project_name), 

673 project_name=project_name, 

674 ) 

675 last_error: Exception | None = None 

676 for attempt in range(_PROFILE_PROPAGATION_RETRIES): 

677 try: 

678 instance_id = _clean_scalar(_run_aws(cmd)) 

679 return _validate(instance_id, _INSTANCE_RE, "launched instance id") 

680 except RuntimeError as exc: # bounded retry loop 

681 last_error = exc 

682 if ( 

683 "Invalid IAM Instance Profile" not in str(exc) 

684 and "instance profile" not in str(exc).lower() 

685 ): 

686 raise 

687 time.sleep(_PROFILE_PROPAGATION_WAIT_SECONDS) 

688 logger.info( 

689 "Instance profile not yet visible to EC2 (attempt %d); retrying launch.", 

690 attempt + 1, 

691 ) 

692 raise RuntimeError( 

693 f"run-instances failed after {_PROFILE_PROPAGATION_RETRIES} attempts " 

694 f"waiting for instance-profile propagation: {last_error}" 

695 ) 

696 

697 

698def _is_instance_type_unavailable(error: Exception) -> bool: 

699 """Return whether ``error`` means the type can't launch in this subnet. 

700 

701 Matches EC2's ``InsufficientInstanceCapacity`` (transient capacity) and 

702 ``Unsupported`` family ("not supported in your requested Availability 

703 Zone", Regions that never offered the type). Deliberately narrow: 

704 authorization, vCPU-quota, and malformed-request errors do not match, so 

705 they fail immediately instead of burning the fallback chain. 

706 """ 

707 message = str(error) 

708 return any(marker in message for marker in _INSTANCE_TYPE_UNAVAILABLE_MARKERS) 

709 

710 

711def launch_bastion_with_fallback( 

712 *, 

713 network: BastionNetwork, 

714 ami_id: str, 

715 region: str, 

716 ttl_minutes: int, 

717 project_name: str = DEFAULT_PROJECT_NAME, 

718 instance_types: tuple[str, ...] = (BASTION_INSTANCE_TYPE, *BASTION_INSTANCE_TYPE_FALLBACKS), 

719) -> tuple[str, str]: 

720 """Launch the bastion, walking the type chain when a type is unavailable. 

721 

722 Tries each candidate in order and returns ``(instance_id, instance_type)`` 

723 for the first successful launch. Only unavailability errors advance the 

724 chain; every other failure propagates unchanged from :func:`launch_bastion` 

725 (which still owns the instance-profile propagation retry per attempt). 

726 Raises the last unavailability error when every candidate is exhausted. 

727 """ 

728 if not instance_types: 

729 raise ValueError("instance_types must name at least one candidate") 

730 last_error: Exception | None = None 

731 for candidate in instance_types: 

732 try: 

733 instance_id = launch_bastion( 

734 network=network, 

735 ami_id=ami_id, 

736 region=region, 

737 ttl_minutes=ttl_minutes, 

738 project_name=project_name, 

739 instance_type=candidate, 

740 ) 

741 except RuntimeError as exc: # bounded fallback chain 

742 if not _is_instance_type_unavailable(exc): 

743 raise 

744 last_error = exc 

745 logger.warning( 

746 "Instance type %s is unavailable in %s (%s); trying the next fallback.", 

747 candidate, 

748 region, 

749 network.subnet_id, 

750 ) 

751 continue 

752 if candidate != instance_types[0]: 

753 logger.info( 

754 "Launched ephemeral bastion on fallback instance type %s " 

755 "(preferred %s unavailable).", 

756 candidate, 

757 instance_types[0], 

758 ) 

759 return instance_id, candidate 

760 raise RuntimeError( 

761 f"No bastion instance type in {list(instance_types)} could be launched in " 

762 f"{region} ({network.subnet_id}); last error: {last_error}" 

763 ) 

764 

765 

766def wait_until_ssm_online( 

767 instance_id: str, 

768 region: str, 

769 *, 

770 timeout_seconds: float = 240.0, 

771 poll_interval_seconds: float = 10.0, 

772) -> None: 

773 """Block until the instance registers with SSM (PingStatus=Online) or time out.""" 

774 deadline = time.monotonic() + timeout_seconds 

775 cmd = build_describe_ssm_ping_command(instance_id, region) 

776 while time.monotonic() < deadline: 

777 status = _clean_scalar(_run_aws(cmd)) 

778 if status == "Online": 

779 return 

780 time.sleep(poll_interval_seconds) 

781 raise RuntimeError( 

782 f"Instance {instance_id} did not come Online in SSM within " 

783 f"{int(timeout_seconds)}s. Check that the private subnet can reach the SSM service " 

784 "through NAT egress or Systems Manager interface VPC endpoints." 

785 ) 

786 

787 

788def create_ephemeral_bastion( 

789 cluster: str, 

790 region: str, 

791 *, 

792 project_name: str = DEFAULT_PROJECT_NAME, 

793 ttl_minutes: int = DEFAULT_TTL_MINUTES, 

794 wait_online: bool = True, 

795) -> str: 

796 """Provision a self-terminating SSM bastion in the cluster VPC. Returns its id. 

797 

798 The IAM role / instance profile are named for ``project_name`` (default 

799 ``gco``) so they match the deployment's other project-scoped resources. 

800 """ 

801 _validate(cluster, _CLUSTER_RE, "cluster name") 

802 _validate(region, _REGION_RE, "region") 

803 _validate_project(project_name) 

804 ttl = _validate_ttl(ttl_minutes) 

805 

806 ami_id = resolve_bastion_ami(region) 

807 network = resolve_bastion_network(cluster, region) 

808 ensure_bastion_iam(project_name, region) 

809 instance_id, launched_type = launch_bastion_with_fallback( 

810 network=network, ami_id=ami_id, region=region, ttl_minutes=ttl, project_name=project_name 

811 ) 

812 logger.info( 

813 "Launched ephemeral bastion %s (%s) in %s (%s).", 

814 instance_id, 

815 launched_type, 

816 region, 

817 network.subnet_id, 

818 ) 

819 if wait_online: 

820 try: 

821 wait_until_ssm_online(instance_id, region) 

822 except Exception: 

823 # Atomic create: never leak the instance we just launched if it 

824 # fails to register with SSM. The self-terminate user-data is only a 

825 # last-resort backstop, not the normal cleanup path. 

826 try: 

827 destroy_ephemeral_bastion(instance_id, region, project_name=project_name) 

828 except Exception: # pragma: no cover - best effort 

829 logger.exception( 

830 "Failed to clean up bastion %s after online-wait failure", instance_id 

831 ) 

832 raise 

833 return instance_id 

834 

835 

836def destroy_ephemeral_bastion( 

837 instance_id: str, 

838 region: str, 

839 *, 

840 project_name: str = DEFAULT_PROJECT_NAME, 

841 delete_iam: bool = True, 

842) -> None: 

843 """Terminate the bastion and (best-effort) delete its IAM role + profile. 

844 

845 Termination is the cost-critical step and is always attempted. IAM cleanup is 

846 best-effort: a leftover role/instance-profile costs nothing and is greppable 

847 by its ``gco:ephemeral`` tag, so a failure here is logged, not raised. The 

848 role / profile deleted are the ones named for ``project_name``. 

849 """ 

850 _validate(instance_id, _INSTANCE_RE, "instance id") 

851 _validate(region, _REGION_RE, "region") 

852 _validate_project(project_name) 

853 

854 _run_aws(build_terminate_instances_command(instance_id, region)) 

855 logger.info("Terminating ephemeral bastion %s.", instance_id) 

856 

857 if not delete_iam: 

858 return 

859 teardown = build_iam_teardown_commands( 

860 bastion_role_name(project_name), 

861 bastion_profile_name(project_name), 

862 region, 

863 ) 

864 for step in teardown: 

865 try: 

866 _run_aws(step, allow_exists=True) 

867 except RuntimeError as exc: # best-effort — never mask the termination 

868 logger.warning("IAM teardown step failed (%s): %s", " ".join(step[:3]), exc) 

869 

870 

871@contextmanager 

872def ephemeral_bastion( 

873 cluster: str, 

874 region: str, 

875 *, 

876 project_name: str = DEFAULT_PROJECT_NAME, 

877 ttl_minutes: int = DEFAULT_TTL_MINUTES, 

878) -> Iterator[str]: 

879 """Context manager: create a bastion, yield its id, guarantee teardown.""" 

880 instance_id = create_ephemeral_bastion( 

881 cluster, region, project_name=project_name, ttl_minutes=ttl_minutes 

882 ) 

883 try: 

884 yield instance_id 

885 finally: 

886 destroy_ephemeral_bastion(instance_id, region, project_name=project_name)