Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions infra/vpcopilot-lab/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Tooling scratch — never commit.
.claude-flow/
.swarm/

# Secrets and local state — never commit.
.secrets/
*.pem
*.pub
terraform.tfvars
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.tfplan
tfplan
crash.log
crash.*.log
105 changes: 105 additions & 0 deletions infra/vpcopilot-lab/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# vpcopilot-lab — provision + COST CONTROL for the Advanced-WAF BIG-IP lab.
# Every stop/start is scoped to Project=$(PROJECT) so it can never touch another
# estate. Override on the command line, e.g. make lab-down AWS_PROFILE=vpcopilot
#
# make check verify tooling + profile + subscription readiness
# make plan/apply stand the lab up (Terraform)
# make lab-down STOP the instances (kills EC2 + PAYG hourly; keeps config)
# make lab-up START them again (no re-onboarding needed)
# make lab-status show instance states
# make tunnel open the SSM port-forward to BIG-IP management
# make origin-deploy build+run Larkspur on the origin over SSM
# make origin-shell SSM shell on the origin
# make lab-nuke terraform destroy (zero idle cost; re-onboard on next apply)

AWS_PROFILE ?= vpcopilot
REGION ?= us-east-2
PROJECT ?= vpcopilot-lab
APP_PORT ?= 8080
MGMT_PORT ?= 443
LOCAL_PORT ?= 18443

AWS := aws --profile $(AWS_PROFILE) --region $(REGION)
TAGF := Name=tag:Project,Values=$(PROJECT)

.PHONY: check init plan apply lab-status lab-down lab-up tunnel origin-deploy origin-shell lab-nuke whoami keypair

check:
@echo "profile : $(AWS_PROFILE)"; echo "region : $(REGION)"
@command -v terraform >/dev/null && echo "terraform: $$(terraform version | head -1)" || echo "terraform: MISSING"
@command -v session-manager-plugin >/dev/null && echo "ssm-plugin: present" || echo "ssm-plugin: MISSING (brew install --cask session-manager-plugin)"
@$(AWS) sts get-caller-identity --query 'Account' --output text >/dev/null 2>&1 \
&& echo "identity : $$($(AWS) sts get-caller-identity --query 'Account' --output text)" \
|| { echo "identity : profile '$(AWS_PROFILE)' not configured — run: aws configure --profile $(AWS_PROFILE)"; exit 1; }
@echo -n "awaf ami : "; $(AWS) ec2 describe-images --owners 679593333241 \
--filters 'Name=name,Values=*Adv WAF*' --query 'length(Images)' --output text 2>/dev/null \
| awk '{if ($$1+0>0) print $$1" Advanced-WAF images visible (subscription OK)"; else print "0 — accept the AWAF Marketplace subscription first"}'

keypair:
@test -f .secrets/vpcopilot_lab.pub && echo ".secrets/ keypair already present" || { \
mkdir -p .secrets && ssh-keygen -t rsa -b 4096 -N '' -f .secrets/vpcopilot_lab.pem \
&& mv .secrets/vpcopilot_lab.pem.pub .secrets/vpcopilot_lab.pub && echo "generated .secrets/vpcopilot_lab.{pem,pub}"; }

init:
terraform init

plan: keypair
terraform plan

apply: keypair
terraform apply

whoami:
@$(AWS) sts get-caller-identity --output table

lab-status:
@$(AWS) ec2 describe-instances --filters "$(TAGF)" \
--query 'Reservations[].Instances[].{ID:InstanceId,Name:Tags[?Key==`Name`]|[0].Value,State:State.Name,Type:InstanceType}' \
--output table

lab-down:
@ids=$$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=instance-state-name,Values=running,pending" \
--query 'Reservations[].Instances[].InstanceId' --output text); \
if [ -z "$$ids" ]; then echo "no running $(PROJECT) instances"; else \
echo "stopping (Project=$(PROJECT)): $$ids"; \
$(AWS) ec2 stop-instances --instance-ids $$ids \
--query 'StoppingInstances[].{id:InstanceId,state:CurrentState.Name}' --output table; fi

lab-up:
@ids=$$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=instance-state-name,Values=stopped,stopping" \
--query 'Reservations[].Instances[].InstanceId' --output text); \
if [ -z "$$ids" ]; then echo "no stopped $(PROJECT) instances"; else \
echo "starting: $$ids"; $(AWS) ec2 start-instances --instance-ids $$ids >/dev/null; \
$(AWS) ec2 wait instance-running --instance-ids $$ids; \
echo "running. open the mgmt tunnel with: make tunnel"; fi

# --- origin id / bigip mgmt ip resolved at run time (from tags / terraform state)
ORIGIN_ID = $$($(AWS) ec2 describe-instances --filters "$(TAGF)" "Name=tag:Role,Values=origin" \
"Name=instance-state-name,Values=running" --query 'Reservations[0].Instances[0].InstanceId' --output text)

tunnel:
@oid=$(ORIGIN_ID); mgmt=$$(terraform output -raw bigip_mgmt_private_ip); \
echo "forwarding 127.0.0.1:$(LOCAL_PORT) -> $$mgmt:$(MGMT_PORT) via $$oid"; \
echo "then: export BIGIP_URL=https://127.0.0.1:$(LOCAL_PORT)"; \
$(AWS) ssm start-session --target $$oid \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters "host=$$mgmt,portNumber=$(MGMT_PORT),localPortNumber=$(LOCAL_PORT)"

origin-shell:
@$(AWS) ssm start-session --target $(ORIGIN_ID)

# Ship labs/larkspur-bank (two levels up) to the origin over SSM and run it. The
# tree is a handful of small files, well within the RunShellScript payload limit.
origin-deploy:
@oid=$(ORIGIN_ID); \
echo "packaging labs/larkspur-bank -> $$oid"; \
tar czf /tmp/larkspur.tgz -C ../../labs larkspur-bank; \
b64=$$(base64 < /tmp/larkspur.tgz | tr -d '\n'); \
cid=$$($(AWS) ssm send-command --instance-ids $$oid --document-name AWS-RunShellScript \
--comment "deploy larkspur" \
--parameters commands="[\"set -e\",\"mkdir -p /opt/vpcopilot\",\"echo $$b64 | base64 -d > /tmp/larkspur.tgz\",\"tar xzf /tmp/larkspur.tgz -C /opt/vpcopilot\",\"cd /opt/vpcopilot/larkspur-bank\",\"docker build -t larkspur .\",\"docker rm -f larkspur 2>/dev/null || true\",\"docker run -d --restart unless-stopped --name larkspur -p $(APP_PORT):8080 larkspur\"]" \
--query 'Command.CommandId' --output text); \
echo "command $$cid dispatched; tail with: $(AWS) ssm get-command-invocation --command-id $$cid --instance-id $$oid"

lab-nuke:
terraform destroy
89 changes: 89 additions & 0 deletions infra/vpcopilot-lab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# infra/vpcopilot-lab — the copilot's Advanced-WAF BIG-IP lab (Terraform)

Stands up the copilot's own validation estate in **us-east-2**, all tagged
`Project=vpcopilot-lab`:

- **BIG-IP Advanced WAF VE** (`m5.xlarge`, F5 PAYG AWAF image) — two NICs, a public
VIP Elastic IP, management reachable **only through an SSM port-forward** (never
internet-published).
- **Larkspur Bank origin** (`t3.small`, Docker) behind the BIG-IP, on `10.30.10.22:8080`.
- Dedicated VPC `10.30.0.0/16`, deliberately separate from `nimbus-demo`.

It is adapted from `nimbus-demo/infra` with three deliberate changes: the **AWAF**
SKU (not GOOD — GOOD has no ASM and can't validate the WAF emitter), **Larkspur**
as the origin, and **no load generators**. The BIG-IP dataplane/WAF build is done
out-of-band by `onboard/bigip-onboard.sh` — nothing sensitive lands in state.

Credentials never live in this repo: the AWS profile is in `~/.aws/credentials`,
the SSH private key stays under `.secrets/` (gitignored), and Terraform state is
gitignored.

## 1. New-account prerequisites

```sh
# a named profile for the NEW account (credentials only ever land here)
aws configure --profile vpcopilot # region us-east-2

# the AWAF Marketplace subscription must be accepted in the new account
# Marketplace → "F5 BIG-IP Advanced WAF ... PAYG" → Subscribe
brew install --cask session-manager-plugin # for the mgmt tunnel

make check # verifies profile, plugin, and that AWAF AMIs are visible
```

`make check` reports `awaf ami : 0 — accept the AWAF Marketplace subscription
first` until the subscription is live — that's the one prerequisite Terraform
can't create for you.

## 2. Apply

```sh
cp terraform.tfvars.example terraform.tfvars # set aws_profile, xc_re_cidrs
make init
make plan # generates .secrets/ keypair first
make apply
```

Key outputs: `bigip_vip_eip` (re-point the KEPT XC tenant's copilot-lab origin pool
here — **DNS is unchanged**; `banknimbus.com` hostnames resolve to XC's edge, not to
this EIP), `ssm_tunnel_command`, and `bigip_lab_create_hint`.

## 3. Onboard the BIG-IP (out-of-band)

The PAYG AMI self-licenses on boot, but ASM must be provisioned and AS3 installed —
`onboard/bigip-onboard.sh` does both and closes the two traps that cost real time
the first time (ASM provisioned before `mcpd` is up; AS3 absent from the image):

```sh
make tunnel # SSM forward to BIG-IP mgmt; leave it running
# in another shell, push+run the onboard script over the tunnel (see script header)
```

Then deploy the origin app and build the AS3 tenant:

```sh
make origin-deploy # build+run Larkspur on the origin over SSM
export BIGIP_URL=https://127.0.0.1:18443
vpcopilot bigip-lab create --origin 10.30.10.22:8080 --virtual-address 10.30.10.190
```

## 4. Cost control — turn it off when idle

The AWAF appliance bills for EC2 **and** a PAYG software fee, but only while
`running`. Every target below is scoped to `Project=vpcopilot-lab`.

| command | effect | idle cost |
|---|---|---|
| `make lab-down` | **stop** both instances — kills EC2 + PAYG hourly, keeps the onboarded config on EBS (no re-onboarding) | EBS + ~2 EIPs (≈$7/mo) |
| `make lab-up` | start them again; fixed IPs and EIPs persist | — |
| `make lab-nuke` | `terraform destroy` — releases EIPs, deletes volumes | ~$0 (re-onboard next apply) |

Daily rhythm: `make lab-up` to work, `make lab-down` when done. `make lab-nuke`
for breaks of days or more. Set `bigip_mgmt_eip = false` to shave one EIP.

## Plugs into the migration runbook

This module is **P3** of the migration. It assumes the kept XC tenant
(`f5-amer-ent` / `d-henley`) — populate `xc_re_cidrs` from it and point its origin
pool at `bigip_vip_eip`. The origin's fixed IPs match the documented CLI examples
so `vpcopilot bigip-lab create` copy-pastes.
80 changes: 80 additions & 0 deletions infra/vpcopilot-lab/bigip.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# BIG-IP Advanced WAF VE, two interfaces:
# eth0 (device_index 0) = management -> mgmt subnet, no public inbound (SSM tunnel)
# eth1 (device_index 1) = external -> dataplane subnet, self-IP + VIP secondary
# The public virtual server (443) is built by the copilot's AS3 tenant
# (`vpcopilot bigip-lab create`) after ASM is provisioned and AS3 is installed by
# onboard/bigip-onboard.sh. None of that lands in Terraform state.

resource "aws_network_interface" "bigip_mgmt" {
subnet_id = aws_subnet.mgmt.id
security_groups = [aws_security_group.bigip_mgmt.id]
description = "BIG-IP management (eth0)"

tags = {
Name = "${var.project}-bigip-mgmt"
}
}

resource "aws_network_interface" "bigip_external" {
subnet_id = aws_subnet.external.id
security_groups = [aws_security_group.bigip_external.id]
source_dest_check = false
description = "BIG-IP external/dataplane (eth1): self-IP + VIP secondary IP"

# [0] primary = the self-IP; [1] secondary = the VIP the Elastic IP maps to.
private_ip_list_enabled = true
private_ip_list = [var.bigip_external_self_ip, var.bigip_vip_ip]

tags = {
Name = "${var.project}-bigip-external"
}
}

resource "aws_instance" "bigip" {
ami = local.bigip_ami
instance_type = var.bigip_instance_type
key_name = aws_key_pair.lab.key_name

network_interface {
device_index = 0
network_interface_id = aws_network_interface.bigip_mgmt.id
}

network_interface {
device_index = 1
network_interface_id = aws_network_interface.bigip_external.id
}

tags = {
Name = "${var.project}-bigip"
Role = "bigip" # the Makefile filters stop/start targets on this
}
}

# Public VIP Elastic IP -> the external interface's secondary (VIP) private IP.
# The XC origin pool / DNS points here.
resource "aws_eip" "bigip_vip" {
domain = "vpc"
network_interface = aws_network_interface.bigip_external.id
associate_with_private_ip = var.bigip_vip_ip

tags = {
Name = "${var.project}-bigip-vip-eip"
}

depends_on = [aws_instance.bigip]
}

# Optional management Elastic IP for guaranteed egress (AS3 download, updates).
# Inbound stays closed to the internet via the mgmt SG. Toggle with bigip_mgmt_eip.
resource "aws_eip" "bigip_mgmt" {
count = var.bigip_mgmt_eip ? 1 : 0
domain = "vpc"
network_interface = aws_network_interface.bigip_mgmt.id

tags = {
Name = "${var.project}-bigip-mgmt-eip"
}

depends_on = [aws_instance.bigip]
}
46 changes: 46 additions & 0 deletions infra/vpcopilot-lab/data.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Ubuntu 22.04 for the Larkspur origin (Docker host + SSM tunnel target).
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}

# Resolve the F5 Advanced-WAF PAYG image ONLY when bigip_ami is not pinned. Gating
# with count means a stale/unsubscribed name filter can't error a plan that pins
# the AMI explicitly. If the filter matches nothing, the plan fails loudly (better
# than silently landing on the wrong SKU) — usually it means the AWAF Marketplace
# subscription isn't accepted in this account yet.
data "aws_ami" "bigip" {
count = var.bigip_ami == "" ? 1 : 0
most_recent = true
owners = [var.bigip_ami_owner]

filter {
name = "name"
values = [var.bigip_ami_name_filter]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}

locals {
bigip_ami = var.bigip_ami != "" ? var.bigip_ami : data.aws_ami.bigip[0].id
}
31 changes: 31 additions & 0 deletions infra/vpcopilot-lab/iam.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# The origin carries an SSM instance role so it can be an `aws ssm start-session`
# target — it is both the shell host and the near end of the BIG-IP mgmt tunnel.
# No inbound SSH rule is ever needed.
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}

resource "aws_iam_role" "origin" {
name = "${var.project}-origin-role"
assume_role_policy = data.aws_iam_policy_document.assume.json

tags = {
Name = "${var.project}-origin-role"
}
}

resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.origin.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "origin" {
name = "${var.project}-origin-profile"
role = aws_iam_role.origin.name
}
11 changes: 11 additions & 0 deletions infra/vpcopilot-lab/keys.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SSH keypair for BIG-IP admin (reached over the SSM tunnel). The private half
# stays under ./.secrets/ (gitignored) and never enters Terraform state. The
# origin uses SSM Session Manager, so it needs no key.
resource "aws_key_pair" "lab" {
key_name = "${var.project}-key"
public_key = file(var.ssh_public_key_path)

tags = {
Name = "${var.project}-key"
}
}
Loading
Loading