Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4164434
initial ec2 module
mabadir Jul 5, 2026
8cac802
initial ec2 module
mabadir Jul 9, 2026
59ba615
Merge branch 'main' into ma/ec2-module
mabadir Jul 9, 2026
46aa692
Refine EC2 module deploy model and logging
mabadir Jul 10, 2026
d99fdf8
Add authenticated Git checkout for EC2 manual deploys
mabadir Jul 11, 2026
b79ab3e
Rename EC2 manual start command and embed deploy script in SSM
mabadir Jul 13, 2026
0a3e7ff
Install git for EC2 source-backed deploys
mabadir Jul 13, 2026
745b1bb
Capture SSM deploy output in instance logs for EC2 manual and container
mabadir Jul 13, 2026
5fc4534
Merge branch 'main' into ma/ec2-module
mabadir Jul 14, 2026
d01da8d
Refine shared build inputs across module definitions
mabadir Jul 14, 2026
f55aff8
Add visibility-only EC2 Auto Scaling lifecycle hooks
mabadir Jul 15, 2026
046ab06
Remove obsolete module-definition scaffolding
mabadir Jul 15, 2026
75c8cbe
Rename EC2 service ALB inputs and fix image URI wiring
mabadir Jul 15, 2026
d688e0c
add image uri validation
mabadir Jul 17, 2026
a2b4edf
Merge branch 'main' into ma/ec2-module
mabadir Jul 17, 2026
68e8dc2
bring into alingment with ecs web
flybayer Jul 18, 2026
c33f0ea
improvements
flybayer Jul 19, 2026
e723e26
fixes and improvements
flybayer Jul 19, 2026
1a2fd91
more
flybayer Jul 19, 2026
a51bc55
Merge branch 'ma/ec2-module' into bb-ec2
flybayer Jul 19, 2026
1680610
Merge pull request #67 from flightcontrolhq/bb-ec2
flybayer Jul 21, 2026
04ddf8c
Fix ravion modules test helpers
flybayer Jul 22, 2026
308558e
ec2_service: restore root-shell HOME and TERM for deploy and app runn…
mabadir Jul 23, 2026
409dfdc
tools: repair compiler test after ec2/ecs merge
mabadir Jul 23, 2026
253601f
update ALB def
flybayer Jul 23, 2026
10348fb
simplify
flybayer Jul 23, 2026
20c8ab3
fix
flybayer Jul 23, 2026
6ee2543
Merge pull request #71 from flightcontrolhq/bb-alb
flybayer Jul 23, 2026
9f1722f
Merge remote-tracking branch 'origin/main' into ma/ec2-module
flybayer Jul 24, 2026
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This repository contains reusable infrastructure modules designed for enterprise
| `cache/` | `elasticache` | AWS ElastiCache clusters (Redis, Valkey, Memcached) | v1.0.0 |
| `cdn/` | `cloudfront` | AWS CloudFront distributions with origins, cache behaviors, and edge redirects (includes `rvn-cloudfront` module definition) | v1.0.0 |
| `compute/` | `autoscaling` | AWS Auto Scaling groups | v1.0.0 |
| `compute/` | `ec2` | AWS EC2 instances | Planned |
| `compute/` | `ec2_service` | Supervised EC2 workloads with configurable rolling deploys, standalone or ECS-cluster ALB routing, target tuning, and deployment-scoped CloudWatch logs | v1.0.0 |
| `compute/` | `ecs_cluster` | AWS ECS clusters with Fargate/EC2 capacity providers and optional ALBs/NLBs | v1.0.0 |
| `compute/` | `ecs_service` | AWS ECS services with task definitions, task IAM policies, load balancing, and auto scaling | v1.0.0 |
| `compute/` | `lambda` | AWS Lambda functions | v1.0.0 |
Expand All @@ -33,7 +33,7 @@ This repository contains reusable infrastructure modules designed for enterprise
| `messaging/` | `sns` | AWS SNS topics and subscriptions | Planned |
| `messaging/` | `sqs` | AWS SQS queues | Planned |
| `monitoring/` | `cloudwatch` | AWS CloudWatch alarms and dashboards | Planned |
| `networking/` | `alb` | AWS Application Load Balancers | v1.0.0 |
| `networking/` | `alb` | Standalone AWS Application Load Balancer with shared HTTP/HTTPS listeners | v1.0.0 |
| `networking/` | `eips` | AWS Elastic IP pool with deterministic Name tags and `/32` CIDR outputs | v1.0.0 |
| `networking/` | `nlb` | AWS Network Load Balancers | v1.0.0 |
| `networking/` | `route53` | AWS Route53 hosted zones and records | v1.0.0 |
Expand Down
39 changes: 39 additions & 0 deletions compute/ec2_service/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

196 changes: 196 additions & 0 deletions compute/ec2_service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# EC2 Service

Runs an app on a stable group of EC2 instances behind an optional Application Load Balancer, with in-place deploys pushed through SSM Run Command. Instances are managed by an Auto Scaling Group but are never replaced by deploys, so per-instance state (root volume, optional data volume) survives every release.

Two runtimes are supported:

- **container** — each deploy pulls an image and runs the attached Docker process under supervisord.
- **manual** — deploys can check out an authenticated Git source, run release preparation commands, then start the configured long-lived command under supervisord.

Instances install the prerequisites for both runtimes at launch, so `runtime` can switch between `container` and `manual` without replacing the instance group. Supervisord owns the app in both modes and restarts it after an unexpected exit.

## How deploys work

For the **container** runtime the module creates an SSM Command document (`<name>-deploy` — the name is a platform contract derived from the group name) that encodes the whole in-place deploy for one instance:

1. Rebuild the app env file: Terraform-rendered plain values, plus secret values fetched on the instance from Secrets Manager / SSM Parameter Store.
2. Drain: deregister the instance from the target group and wait (skipped in worker mode, and when the instance is the only registered target — with nothing to shift traffic to, draining only lengthens the outage).
3. Swap the release: `docker pull`, then update the supervisord-managed container process.
4. Health gate: poll `http://localhost:<app_port><deploy_health_check_path>` until healthy, or fail the command.
5. Re-register the instance with the target group and wait until in service.

An orchestrator (the Ravion deploy manager) runs this document against the Auto Scaling Group's instances with its own batching and failure policy, passing:

| Parameter | Value |
|-----------|-------|
| `imageUri` | Full image URI including tag or digest |
| `deployId` | Optional release identifier |

The module definition exposes the rolling deployment batch and failure limits. It defaults to one instance at a time and stops after the first failure. The SSM script timeout applies per instance; the module deployment has a separate 24-hour overall safety limit.

For the **manual** runtime the document (same `<name>-deploy` name) takes a `commands` parameter and an optional Git source. When source is present, the instance fetches a temporary credential from SSM Parameter Store, performs a clean checkout under `/srv/ravion/<name>/source`, and runs both the preparation commands and `manual_start_command` from the selected base path. When source is absent, commands keep their existing working-directory behavior. Any failure stops the deploy. The start command must remain in the foreground rather than daemonizing. Draining and health checking are up to the preparation commands.

App stdout and stderr are shipped to `/ravion/ec2/<name>`. Streams use `deployment/<deployId>/instance/<instance-id>`, which keeps every deployment and EC2 instance separate. The SSM deploy script tees its stdout and stderr into the same instance stream while preserving the native SSM command output.

New instances launched by the Auto Scaling Group boot from the launch template but hold no release until the orchestrator repeats the deploy against them.

## Usage

### Web service running a container

```hcl
module "web" {
source = "git::https://github.com/flightcontrolhq/modules.git//compute/ec2_service?ref=v1.0.0"

name = "my-web"
vpc_id = "vpc-0123456789abcdef0"
subnet_ids = ["subnet-aaa", "subnet-bbb"]

runtime = "container"
instance_type = "t3.small"
min_size = 2
max_size = 4

app_port = 3000
deploy_health_check_path = "/health"

ecr_repository_creation_enabled = true

load_balancer_attachment = {
target_group = {
port = 3000
health_check = {
path = "/health"
}
}
listener_rules = [
{
listener_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/shared/abc/def"
conditions = [
{ type = "host-header", values = ["app.example.com"] }
]
}
]
}
load_balancer_security_group_id = "sg-0123456789abcdef0"

environment_variables = [
{ name = "NODE_ENV", value = "production" }
]
secrets = [
{ name = "DATABASE_URL", value_from = "arn:aws:ssm:us-east-1:123456789012:parameter/my-app/database-url" }
]
}
```

### Worker with manual command deploys

```hcl
module "worker" {
source = "git::https://github.com/flightcontrolhq/modules.git//compute/ec2_service?ref=v1.0.0"

name = "my-worker"
vpc_id = "vpc-0123456789abcdef0"
subnet_ids = ["subnet-aaa", "subnet-bbb"]

runtime = "manual"
instance_type = "t3.small"

manual_start_command = "cd /srv/app && ./bin/worker"

data_volume_creation_enabled = true
data_volume_size = 50
}
```

Deploys then run your release preparation commands on every instance, with the app env file refreshed and loaded first:

```sh
aws ssm send-command \
--document-name my-worker-deploy \
--targets Key=tag:aws:autoscaling:groupName,Values=my-worker \
--parameters commands='["cd /srv/app && git pull","cd /srv/app && ./bin/migrate"]'
```

## Stable storage

- The root volume and optional data volume are per-instance EBS. They survive every in-place deploy because instances are not replaced, but they are lost when the Auto Scaling Group terminates the instance.
- For storage that must survive instance termination, mount an EFS file system (`efs_*` variables); it is mounted on every instance and, for the container runtime, bind-mounted into the app container.
- Launch template changes (AMI, user data, volumes) intentionally apply only to newly launched instances; there is no instance refresh.

## Requirements

| Name | Version |
|------|---------|
| opentofu/terraform | >= 1.10.0 |
| aws | >= 6.0 |

Instances need outbound access to SSM, ECR/S3, CloudWatch Logs, PyPI for the pinned Supervisor installation, and (when secrets are configured) Secrets Manager (NAT gateway, public IPs, or VPC endpoints). The default AMI is Amazon Linux 2023; custom AMIs must run cloud-init and include the SSM agent.

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|----------|
| name | Name prefix for all resources (1-28 chars) | `string` | n/a | yes |
| tags | Tags for all resources | `map(string)` | `{}` | no |
| region | AWS region (null = provider region) | `string` | `null` | no |
| vpc_id | VPC for the instances | `string` | n/a | yes |
| subnet_ids | Subnets for the Auto Scaling Group | `list(string)` | n/a | yes |
| public_ip_assignment_enabled | Assign public IPs to instances | `bool` | `false` | no |
| additional_security_group_ids | Extra security groups on the instances | `list(string)` | `[]` | no |
| direct_access_cidr_blocks | IPv4 CIDRs allowed to reach the app port directly | `list(string)` | `[]` | no |
| runtime | `container` or `manual` | `string` | n/a | yes |
| app_port | Port the app listens on | `number` | `null` | no |
| container_start_command | Optional container start command overriding the image CMD | `string` | `null` | no |
| manual_start_command | Long-running foreground manual app command managed by supervisord | `string` | `null` | yes for manual |
| environment_variables | Plain env vars written to the app env file | `list(object)` | `[]` | no |
| secrets | Secret env vars fetched on-instance from Secrets Manager / SSM Parameter Store (`{name, value_from}`) | `list(object)` | `[]` | no |
| deploy_health_check_path | Local HTTP path gating deploy success | `string` | `null` | no |
| deploy_timeout_seconds | Per-instance deploy script timeout | `number` | `1200` | no |
| instance_type | EC2 instance type | `string` | n/a | yes |
| ami_id | Custom AMI (null = latest AL2023) | `string` | `null` | no |
| key_name | SSH key pair name | `string` | `null` | no |
| root_volume_size | Root EBS volume size (GB) | `number` | `30` | no |
| root_volume_type | Root EBS volume type | `string` | `"gp3"` | no |
| data_volume_creation_enabled | Attach a formatted per-instance data volume | `bool` | `false` | no |
| data_volume_size | Data volume size (GB) | `number` | `20` | no |
| data_volume_type | Data volume type | `string` | `"gp3"` | no |
| data_volume_mount_path | Host mount path for the data volume | `string` | `"/data"` | no |
| additional_user_data | Extra shell script appended to bootstrap | `string` | `""` | no |
| min_size | Minimum instances | `number` | `1` | no |
| max_size | Maximum instances | `number` | `3` | no |
| desired_capacity | Desired instances (null = group-managed) | `number` | `null` | no |
| health_check_type | ASG health check: `EC2` or `ELB` | `string` | `"EC2"` | no |
| health_check_grace_period | Seconds before ASG health checks apply | `number` | `300` | no |
| cpu_autoscaling_enabled | Scale on average CPU utilization | `bool` | `false` | no |
| cpu_target_value | CPU utilization target (%) | `number` | `70` | no |
| load_balancer_attachment | Target group + listener rules (null = worker mode) | `object` | `null` | no |
| load_balancer_security_group_id | ALB security group allowed to reach the app port | `string` | `null` | no |
| efs_enabled | Mount an EFS file system on every instance | `bool` | `false` | no |
| efs_file_system_id | EFS file system ID | `string` | `null` | no |
| efs_access_point_id | EFS access point to mount through | `string` | `null` | no |
| efs_client_security_group_id | EFS client security group attached to instances | `string` | `null` | no |
| efs_mount_path | Host mount path for EFS | `string` | `"/mnt/efs"` | no |
| ecr_repository_creation_enabled | Create an ECR repository for built images | `bool` | `false` | no |
| ecr_force_deletion_enabled | Delete the ECR repository even with images | `bool` | `false` | no |
| ecr_scan_on_push_enabled | Scan images for vulnerabilities after push | `bool` | `true` | no |
| log_retention_in_days | CloudWatch app log retention | `number` | `30` | no |

## Outputs

| Name | Description |
|------|-------------|
| autoscaling_group_name | Name of the Auto Scaling Group deploys target |
| autoscaling_group_arn | ARN of the Auto Scaling Group |
| ssm_document_name | Name of the SSM deploy document |
| ssm_document_arn | ARN of the SSM deploy document |
| ecr_repository_arn | ARN of the service ECR repository (when created) |
| ecr_repository_url | URL of the service ECR repository (when created) |
| target_group_arn | ARN of the service target group (when attached) |
| target_group_arn_suffix | Target group ARN suffix for CloudWatch dimensions |
| security_group_id | ID of the instance security group |
| instance_role_arn | ARN of the instance IAM role |
| log_group_name | CloudWatch log group receiving app logs |
| log_stream_prefix | Prefix of deployment- and instance-scoped app log streams |
| aws_account_id | AWS account ID |
| region | AWS region |
72 changes: 72 additions & 0 deletions compute/ec2_service/autoscaling.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
################################################################################
# Auto Scaling Group
################################################################################

module "autoscaling" {
source = "../autoscaling"

name = var.name
vpc_zone_identifier = var.subnet_ids

# Capacity
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity

# Health checks. EC2 by default: in-place deploys briefly deregister
# instances from the target group, which ELB health checks would treat
# as unhealthy and replace mid-deploy.
health_check_type = var.health_check_type
health_check_grace_period = var.health_check_grace_period

# Use existing launch template (don't create new one)
launch_template_creation_enabled = false
launch_template_id = aws_launch_template.app.id
launch_template_version = "$Latest"

# Register instances with the service target group
target_group_arns = local.load_balancer_creation_enabled ? [aws_lb_target_group.app[0].arn] : []

enabled_metrics = [
"GroupDesiredCapacity",
"GroupInServiceInstances",
]

# Visibility-only lifecycle hooks: they emit "EC2 Instance-launch/-terminate
# Lifecycle Action" EventBridge events that Ravion ingests to show instances
# while they are still Pending/Terminating. Nothing completes the action, so
# the minimum 30s heartbeat with CONTINUE keeps the added launch/terminate
# delay as small as possible.
lifecycle_hooks = [
{
name = "ravion-launch-visibility"
lifecycle_transition = "autoscaling:EC2_INSTANCE_LAUNCHING"
default_result = "CONTINUE"
heartbeat_timeout = 30
},
{
name = "ravion-terminate-visibility"
lifecycle_transition = "autoscaling:EC2_INSTANCE_TERMINATING"
default_result = "CONTINUE"
heartbeat_timeout = 30
},
]

scaling_policies = var.cpu_autoscaling_enabled ? [
{
name = "${var.name}-cpu-target-tracking"
policy_type = "TargetTrackingScaling"

target_tracking_configuration = {
target_value = var.cpu_target_value
predefined_metric_specification = {
predefined_metric_type = "ASGAverageCPUUtilization"
}
}
}
] : []

tags = merge(local.tags, {
Name = var.name
})
}
14 changes: 14 additions & 0 deletions compute/ec2_service/cloudwatch.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
################################################################################
# CloudWatch Log Group
#
# App stdout and stderr from every instance land here. Supervisord writes
# each release to its own file, and the CloudWatch agent publishes it to a
# stream scoped by deployment ID and instance ID.
################################################################################

resource "aws_cloudwatch_log_group" "app" {
name = local.log_group_name
retention_in_days = var.log_retention_in_days

tags = local.tags
}
27 changes: 27 additions & 0 deletions compute/ec2_service/data.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
################################################################################
# Data Sources
################################################################################

# Architecture of the selected instance type, used to pick the matching
# default AMI. No user input needed: the instance type determines it.
data "aws_ec2_instance_type" "selected" {
count = var.ami_id == null ? 1 : 0

instance_type = var.instance_type
}

# Latest Amazon Linux 2023 AMI for the instance type's CPU architecture
data "aws_ssm_parameter" "al2023_ami" {
count = var.ami_id == null ? 1 : 0

name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-${local.cpu_architecture}"
}

# Get current AWS region
data "aws_region" "current" {}

# Get current AWS account ID
data "aws_caller_identity" "current" {}

# Get current AWS partition
data "aws_partition" "current" {}
Loading
Loading