AWS Providers¶
Acteon ships with native AWS provider integrations for SNS, Lambda, EventBridge, SQS, S3, SES (via the email provider), EC2, and Auto Scaling. All eight are first-class citizens -- they implement the same Provider trait, participate in circuit breaking, health checks, and per-provider metrics, and require no external plugins.
Compile-Time Feature Selection¶
AWS providers are not compiled by default. Each provider maps to a feature flag on acteon-server, so you only compile the AWS SDKs you need:
# Only SNS and Lambda
cargo build -p acteon-server --features "aws-sns,aws-lambda"
# All eight AWS providers
cargo build -p acteon-server --features aws-all
| Server Feature | AWS SDK Crate |
|---|---|
aws-sns | aws-sdk-sns |
aws-lambda | aws-sdk-lambda |
aws-eventbridge | aws-sdk-eventbridge |
aws-sqs | aws-sdk-sqs |
aws-ses | aws-sdk-sesv2 |
aws-s3 | aws-sdk-s3 |
aws-ec2 | aws-sdk-ec2 |
aws-autoscaling | aws-sdk-autoscaling |
aws-all | All of the above |
This feature gating reduces a default cargo build from ~688 dependencies to ~480, cutting clean build time roughly in half. If you are not using any AWS providers, you pay zero compile cost for them.
The acteon-aws crate uses the same pattern internally — each provider is behind a matching feature flag (sns, lambda, etc.), and provider registration in main.rs is guarded by #[cfg(feature = "aws-*")].
Overview¶
| Provider | AWS Service | Action Types | Use Case |
|---|---|---|---|
aws-sns | Simple Notification Service | publish | Fan-out alerts to subscriptions (email, SMS, HTTP, Lambda) |
aws-lambda | Lambda | invoke | Run serverless functions for data processing |
aws-eventbridge | EventBridge | put_event | Publish domain events to event buses |
aws-sqs | Simple Queue Service | send_message | Queue messages for async processing |
aws-s3 | Simple Storage Service | put_object, get_object, delete_object | Store and retrieve objects (logs, artifacts, archives) |
ses (email) | Simple Email Service | send_email | Send transactional emails via SES |
aws-ec2 | EC2 | start_instances, stop_instances, reboot_instances, terminate_instances, hibernate_instances, run_instances, attach_volume, detach_volume, describe_instances | Instance lifecycle management, EBS volume operations |
aws-autoscaling | Auto Scaling | describe_auto_scaling_groups, set_desired_capacity, update_auto_scaling_group | Manage Auto Scaling Group capacity and settings |
All AWS providers:
- Share a common authentication layer with automatic STS credential refresh
- Support IAM role assumption with
session_nameandexternal_idfor cross-account access - Support endpoint URL overrides for LocalStack and other AWS-compatible services
- Report per-provider health metrics (success rate, latency percentiles, error tracking)
- Map AWS SDK errors to the standard
ProviderErrorenum for circuit breaker integration
TOML Configuration¶
AWS SNS¶
[[providers]]
name = "alert-fanout"
type = "aws-sns"
aws_region = "us-east-1"
topic_arn = "arn:aws:sns:us-east-1:123456789012:alerts"
# Optional fields:
# aws_endpoint_url = "http://localhost:4566" # LocalStack
# aws_role_arn = "arn:aws:iam::123:role/sns" # Cross-account
# aws_session_name = "acteon-sns" # STS session name
# aws_external_id = "ext-123" # Trust policy external ID
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-sns" |
aws_region | Yes | AWS region |
topic_arn | No | Default SNS topic ARN. Can be overridden per-action in the payload |
aws_endpoint_url | No | Endpoint URL override (for LocalStack) |
aws_role_arn | No | IAM role ARN to assume via STS |
aws_session_name | No | STS session name (defaults to "acteon-aws-provider") |
aws_external_id | No | External ID for cross-account trust policies |
AWS Lambda¶
[[providers]]
name = "anomaly-detector"
type = "aws-lambda"
aws_region = "us-east-1"
function_name = "anomaly-detector"
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-lambda" |
aws_region | Yes | AWS region |
function_name | No | Default Lambda function name. Can be overridden per-action |
aws_endpoint_url | No | Endpoint URL override |
aws_role_arn | No | IAM role ARN to assume |
aws_session_name | No | STS session name |
aws_external_id | No | External ID for cross-account trust policies |
AWS EventBridge¶
[[providers]]
name = "event-bus"
type = "aws-eventbridge"
aws_region = "us-east-1"
event_bus_name = "application-events"
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-eventbridge" |
aws_region | Yes | AWS region |
event_bus_name | No | Default event bus name. Can be overridden per-action |
aws_endpoint_url | No | Endpoint URL override |
aws_role_arn | No | IAM role ARN to assume |
aws_session_name | No | STS session name |
aws_external_id | No | External ID for cross-account trust policies |
AWS SQS¶
[[providers]]
name = "metrics-queue"
type = "aws-sqs"
aws_region = "us-east-1"
queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/metrics"
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-sqs" |
aws_region | Yes | AWS region |
queue_url | No | Default SQS queue URL. Can be overridden per-action |
aws_endpoint_url | No | Endpoint URL override |
aws_role_arn | No | IAM role ARN to assume |
aws_session_name | No | STS session name |
aws_external_id | No | External ID for cross-account trust policies |
AWS S3¶
[[providers]]
name = "archive"
type = "aws-s3"
aws_region = "us-east-1"
bucket_name = "acteon-archive"
object_prefix = "telemetry/"
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-s3" |
aws_region | Yes | AWS region |
bucket_name | No | Default S3 bucket name. Can be overridden per-action |
object_prefix | No | Key prefix prepended to all object keys |
aws_endpoint_url | No | Endpoint URL override |
aws_role_arn | No | IAM role ARN to assume |
aws_session_name | No | STS session name |
aws_external_id | No | External ID for cross-account trust policies |
SES Email Backend¶
SES is configured through the email provider by setting backend = "ses":
[[providers]]
name = "email"
type = "email"
backend = "ses"
from_address = "noreply@example.com"
aws_region = "us-east-1"
# ses_configuration_set = "tracking-set" # Optional tracking config set
# aws_endpoint_url = "http://localhost:4566"
# aws_role_arn = "arn:aws:iam::123:role/ses"
# aws_session_name = "acteon-ses"
# aws_external_id = "ext-123"
See the Email provider documentation for full payload format details.
AWS EC2¶
[[providers]]
name = "compute"
type = "aws-ec2"
aws_region = "us-east-1"
# Optional defaults applied to run_instances when not overridden in the payload:
# default_security_group_ids = ["sg-0123456789abcdef0"]
# default_subnet_id = "subnet-0123456789abcdef0"
# default_key_name = "my-keypair"
# aws_endpoint_url = "http://localhost:4566" # LocalStack
# aws_role_arn = "arn:aws:iam::123:role/ec2" # Cross-account
# aws_session_name = "acteon-ec2" # STS session name
# aws_external_id = "ext-123" # Trust policy external ID
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-ec2" |
aws_region | Yes | AWS region |
default_security_group_ids | No | Default security group IDs for run_instances. Can be overridden per-action |
default_subnet_id | No | Default subnet ID for run_instances. Can be overridden per-action |
default_key_name | No | Default key-pair name for run_instances. Can be overridden per-action |
aws_endpoint_url | No | Endpoint URL override (for LocalStack) |
aws_role_arn | No | IAM role ARN to assume via STS |
aws_session_name | No | STS session name (defaults to "acteon-aws-provider") |
aws_external_id | No | External ID for cross-account trust policies |
AWS Auto Scaling¶
[[providers]]
name = "scaling"
type = "aws-autoscaling"
aws_region = "us-east-1"
# aws_endpoint_url = "http://localhost:4566" # LocalStack
# aws_role_arn = "arn:aws:iam::123:role/asg" # Cross-account
# aws_session_name = "acteon-asg" # STS session name
# aws_external_id = "ext-123" # Trust policy external ID
| Field | Required | Description |
|---|---|---|
name | Yes | Unique provider name used in action dispatch |
type | Yes | Must be "aws-autoscaling" |
aws_region | Yes | AWS region |
aws_endpoint_url | No | Endpoint URL override (for LocalStack) |
aws_role_arn | No | IAM role ARN to assume via STS |
aws_session_name | No | STS session name (defaults to "acteon-aws-provider") |
aws_external_id | No | External ID for cross-account trust policies |
Payload Formats¶
SNS: publish¶
{
"message": "Critical temperature alert: 92°C in server room",
"subject": "Temperature Alert",
"topic_arn": "arn:aws:sns:us-east-1:123:alerts",
"message_group_id": "floor-3",
"message_deduplication_id": "temp-alert-001"
}
| Field | Required | Type | Description |
|---|---|---|---|
message | Yes | string | Message body to publish |
subject | No | string | Subject line (for email subscriptions) |
topic_arn | No | string | Override the default topic ARN |
message_group_id | No | string | FIFO topic group ID |
message_deduplication_id | No | string | FIFO topic dedup ID |
Response:
Lambda: invoke¶
{
"function_name": "anomaly-detector",
"payload": {"device_id": "sensor-001", "value": 92},
"invocation_type": "RequestResponse"
}
| Field | Required | Type | Description |
|---|---|---|---|
function_name | No | string | Override the default function name |
payload | No | object | JSON payload passed to the Lambda function |
invocation_type | No | string | "RequestResponse" (sync, default) or "Event" (async) |
Response:
{
"function_name": "anomaly-detector",
"status_code": 200,
"body": {"anomaly": true, "reason": "temperature_critical"}
}
EventBridge: put_event¶
{
"source": "acteon.iot",
"detail_type": "SensorReading",
"detail": {"device_id": "sensor-001", "value": 92},
"event_bus_name": "building-events"
}
| Field | Required | Type | Description |
|---|---|---|---|
source | Yes | string | Event source identifier |
detail_type | Yes | string | Event type name |
detail | Yes | object/string | Event detail (JSON object or string) |
event_bus_name | No | string | Override the default event bus |
Response:
SQS: send_message¶
{
"message_body": "{\"device_id\": \"sensor-001\", \"value\": 92}",
"queue_url": "https://sqs.us-east-1.amazonaws.com/123/metrics",
"delay_seconds": 10,
"message_group_id": "sensor-001",
"message_deduplication_id": "reading-001"
}
| Field | Required | Type | Description |
|---|---|---|---|
message_body | Yes | string | Message body (JSON string) |
queue_url | No | string | Override the default queue URL |
delay_seconds | No | integer | Delay before the message is visible (0-900) |
message_group_id | No | string | FIFO queue group ID |
message_deduplication_id | No | string | FIFO queue dedup ID |
Response:
S3: put_object¶
{
"key": "telemetry/2026/02/readings.json",
"body": "{\"readings\": [...]}",
"content_type": "application/json",
"bucket": "data-archive",
"metadata": {
"source": "acteon",
"pipeline_version": "2.0"
}
}
| Field | Required | Type | Description |
|---|---|---|---|
key | Yes | string | Object key (path within the bucket) |
body | No | string | Object body as UTF-8 text. Mutually exclusive with body_base64 |
body_base64 | No | string | Object body as base64-encoded bytes. Mutually exclusive with body |
content_type | No | string | MIME type for the object |
bucket | No | string | Override the default bucket |
metadata | No | object | Key-value metadata pairs attached to the object |
Response:
S3: get_object¶
| Field | Required | Type | Description |
|---|---|---|---|
key | Yes | string | Object key to retrieve |
bucket | No | string | Override the default bucket |
Response (UTF-8 content):
{
"bucket": "data-archive",
"key": "telemetry/2026/02/readings.json",
"content_type": "application/json",
"content_length": 1234,
"body": "{\"readings\": [...]}",
"status": "downloaded"
}
Response (binary content):
{
"bucket": "data-archive",
"key": "images/logo.png",
"content_type": "image/png",
"content_length": 5678,
"body_base64": "iVBORw0KGgo...",
"status": "downloaded"
}
S3: delete_object¶
| Field | Required | Type | Description |
|---|---|---|---|
key | Yes | string | Object key to delete |
bucket | No | string | Override the default bucket |
Response:
EC2: start_instances¶
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | Yes | string[] | EC2 instance IDs to start |
Response:
{
"action": "start_instances",
"instance_state_changes": [
{
"instance_id": "i-0abc123def456789a",
"previous_state": "stopped",
"current_state": "pending"
}
]
}
EC2: stop_instances¶
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | Yes | string[] | EC2 instance IDs to stop |
hibernate | No | bool | Hibernate the instances instead of stopping (default false) |
force | No | bool | Force stop without graceful shutdown (default false) |
Response:
{
"action": "stop_instances",
"hibernate": false,
"instance_state_changes": [
{
"instance_id": "i-0abc123def456789a",
"previous_state": "running",
"current_state": "stopping"
}
]
}
EC2: reboot_instances¶
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | Yes | string[] | EC2 instance IDs to reboot |
Response:
EC2: terminate_instances¶
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | Yes | string[] | EC2 instance IDs to terminate |
Response:
{
"action": "terminate_instances",
"instance_state_changes": [
{
"instance_id": "i-0abc123def456789a",
"previous_state": "running",
"current_state": "shutting-down"
}
]
}
EC2: hibernate_instances¶
Sugar action type that internally dispatches stop_instances with hibernate: true.
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | Yes | string[] | EC2 instance IDs to hibernate |
Response: Same as stop_instances with "hibernate": true.
EC2: run_instances¶
{
"image_id": "ami-0abcdef1234567890",
"instance_type": "t3.micro",
"min_count": 1,
"max_count": 2,
"key_name": "my-keypair",
"security_group_ids": ["sg-0123456789abcdef0"],
"subnet_id": "subnet-0123456789abcdef0",
"user_data": "IyEvYmluL2Jhc2gKZWNobyBIZWxsbw==",
"tags": {
"env": "staging",
"team": "platform"
},
"iam_instance_profile": "arn:aws:iam::123456789012:instance-profile/app-profile"
}
| Field | Required | Type | Description |
|---|---|---|---|
image_id | Yes | string | AMI ID to launch |
instance_type | Yes | string | Instance type (e.g. "t3.micro") |
min_count | No | integer | Minimum instances to launch (default 1) |
max_count | No | integer | Maximum instances to launch (default 1) |
key_name | No | string | Key-pair name. Overrides config default_key_name |
security_group_ids | No | string[] | Security group IDs. Overrides config default_security_group_ids |
subnet_id | No | string | Subnet ID. Overrides config default_subnet_id |
user_data | No | string | Base64-encoded user data script |
tags | No | object | Key-value tags applied to launched instances |
iam_instance_profile | No | string | IAM instance profile name or ARN |
Response:
{
"action": "run_instances",
"reservation_id": "r-0abc123def456789a",
"instances": [
{
"instance_id": "i-0abc123def456789a",
"instance_type": "t3.micro",
"state": "pending"
}
]
}
EC2: attach_volume¶
{
"volume_id": "vol-0abc123def456789a",
"instance_id": "i-0abc123def456789a",
"device": "/dev/sdf"
}
| Field | Required | Type | Description |
|---|---|---|---|
volume_id | Yes | string | EBS volume ID to attach |
instance_id | Yes | string | Target EC2 instance ID |
device | Yes | string | Device name (e.g. "/dev/sdf") |
Response:
{
"action": "attach_volume",
"volume_id": "vol-0abc123def456789a",
"instance_id": "i-0abc123def456789a",
"device": "/dev/sdf",
"state": "attaching"
}
EC2: detach_volume¶
{
"volume_id": "vol-0abc123def456789a",
"instance_id": "i-0abc123def456789a",
"device": "/dev/sdf",
"force": false
}
| Field | Required | Type | Description |
|---|---|---|---|
volume_id | Yes | string | EBS volume ID to detach |
instance_id | No | string | Instance to detach from |
device | No | string | Device name |
force | No | bool | Force detachment (default false) |
Response:
{
"action": "detach_volume",
"volume_id": "vol-0abc123def456789a",
"instance_id": "i-0abc123def456789a",
"state": "detaching"
}
EC2: describe_instances¶
| Field | Required | Type | Description |
|---|---|---|---|
instance_ids | No | string[] | Instance IDs to describe. If empty, describes all instances |
Response:
{
"action": "describe_instances",
"instances": [
{
"instance_id": "i-0abc123def456789a",
"instance_type": "t3.micro",
"state": "running",
"public_ip": "54.123.45.67",
"private_ip": "10.0.1.42"
}
]
}
Auto Scaling: describe_auto_scaling_groups¶
| Field | Required | Type | Description |
|---|---|---|---|
auto_scaling_group_names | No | string[] | Group names to describe. If empty, describes all groups |
Response:
{
"action": "describe_auto_scaling_groups",
"auto_scaling_groups": [
{
"auto_scaling_group_name": "web-tier-asg",
"min_size": 2,
"max_size": 10,
"desired_capacity": 4,
"instance_count": 4,
"health_check_type": "ELB"
}
]
}
Auto Scaling: set_desired_capacity¶
| Field | Required | Type | Description |
|---|---|---|---|
auto_scaling_group_name | Yes | string | Auto Scaling Group name |
desired_capacity | Yes | integer | Target capacity |
honor_cooldown | No | bool | Whether to honor the group's cooldown period (default false) |
Response:
{
"action": "set_desired_capacity",
"auto_scaling_group_name": "web-tier-asg",
"desired_capacity": 6,
"status": "updated"
}
Auto Scaling: update_auto_scaling_group¶
{
"auto_scaling_group_name": "web-tier-asg",
"min_size": 2,
"max_size": 20,
"desired_capacity": 8,
"default_cooldown": 300,
"health_check_type": "ELB",
"health_check_grace_period": 120
}
| Field | Required | Type | Description |
|---|---|---|---|
auto_scaling_group_name | Yes | string | Auto Scaling Group name |
min_size | No | integer | New minimum size |
max_size | No | integer | New maximum size |
desired_capacity | No | integer | New desired capacity |
default_cooldown | No | integer | Default cooldown period in seconds |
health_check_type | No | string | Health check type ("EC2" or "ELB") |
health_check_grace_period | No | integer | Health check grace period in seconds |
Response:
{
"action": "update_auto_scaling_group",
"auto_scaling_group_name": "web-tier-asg",
"status": "updated"
}
Authentication and Credential Refresh¶
All AWS providers share a common authentication layer in acteon-aws. Credentials are resolved in this order:
- Default credential chain -- environment variables,
~/.aws/credentials, EC2 instance profile, ECS task role - IAM role assumption (if
aws_role_arnis configured) -- usesAssumeRoleProviderwith automatic credential refresh before expiry
The AssumeRoleProvider handles the STS AssumeRole call internally and refreshes credentials transparently. This means long-running Acteon instances never encounter ExpiredTokenException errors, unlike manual STS calls with static credentials.
Cross-Account Access¶
For multi-account deployments, configure role assumption with an external ID:
[[providers]]
name = "cross-account-sns"
type = "aws-sns"
aws_region = "us-east-1"
aws_role_arn = "arn:aws:iam::987654321098:role/acteon-sns-publisher"
aws_session_name = "acteon-prod"
aws_external_id = "acteon-trust-key"
topic_arn = "arn:aws:sns:us-east-1:987654321098:alerts"
The aws_external_id is validated against the trust policy's Condition block, providing an additional layer of security for cross-account delegation.
Health Check Behavior¶
| Provider | Health Check Method | Notes |
|---|---|---|
aws-sns | ListTopics (max 1) | Verifies API connectivity and credentials |
aws-lambda | ListFunctions (max 1) | Verifies API connectivity and credentials |
aws-eventbridge | ListEventBuses (max 1) | Verifies API connectivity and credentials |
aws-sqs | ListQueues (max 1) | Verifies API connectivity and credentials |
aws-s3 | ListBuckets (max 1) | Verifies API connectivity and credentials |
| SES (email) | GetAccount | Verifies SES sending is enabled |
aws-ec2 | DescribeInstances (dry-run) | DryRunOperation error = healthy; verifies IAM permissions |
aws-autoscaling | DescribeAutoScalingGroups (max 1) | Verifies API connectivity and credentials |
All health checks are lightweight read-only operations that do not modify any resources.
Error Handling¶
AWS SDK errors are classified into the standard ProviderError enum:
| AWS Error Pattern | ProviderError Variant | Retryable | Circuit Breaker |
|---|---|---|---|
| Connection/dispatch failure | Connection | Yes | Counts toward trip |
Throttling / TooManyRequests | RateLimited | Yes | Counts toward trip |
| Timeout | Timeout | Yes | Counts toward trip |
| Credential errors | Configuration | No | Does not count |
| Invalid request | Serialization | No | Does not count |
| Other service errors | ExecutionFailed | No | Counts toward trip |
Example: Dispatching via the API¶
# Publish to SNS
curl -X POST http://localhost:8080/v1/dispatch \
-H "Content-Type: application/json" \
-d '{
"namespace": "alerts",
"tenant": "acme-corp",
"provider": "alert-fanout",
"action_type": "publish",
"payload": {
"message": "Critical alert: CPU at 99%",
"subject": "CPU Alert"
}
}'
# Invoke Lambda
curl -X POST http://localhost:8080/v1/dispatch \
-H "Content-Type: application/json" \
-d '{
"namespace": "processing",
"tenant": "acme-corp",
"provider": "anomaly-detector",
"action_type": "invoke",
"payload": {
"payload": {"device_id": "sensor-001", "value": 92}
}
}'
# Upload to S3
curl -X POST http://localhost:8080/v1/dispatch \
-H "Content-Type: application/json" \
-d '{
"namespace": "archive",
"tenant": "acme-corp",
"provider": "archive",
"action_type": "put_object",
"payload": {
"key": "reports/2026/02/daily.json",
"body": "{\"total\": 42}",
"content_type": "application/json"
}
}'
# Start EC2 instances
curl -X POST http://localhost:8080/v1/dispatch \
-H "Content-Type: application/json" \
-d '{
"namespace": "compute",
"tenant": "acme-corp",
"provider": "compute",
"action_type": "start_instances",
"payload": {
"instance_ids": ["i-0abc123def456789a"]
}
}'
# Scale up an Auto Scaling Group
curl -X POST http://localhost:8080/v1/dispatch \
-H "Content-Type: application/json" \
-d '{
"namespace": "scaling",
"tenant": "acme-corp",
"provider": "scaling",
"action_type": "set_desired_capacity",
"payload": {
"auto_scaling_group_name": "web-tier-asg",
"desired_capacity": 6
}
}'
Example: Rust Client¶
use acteon_client::ActeonClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ActeonClient::new("http://localhost:8080", "your-api-token")?;
// Publish to SNS
client.dispatch_action(
"alerts", "acme-corp", "alert-fanout", "publish",
serde_json::json!({
"message": "Critical alert!",
"subject": "Alert"
}),
).await?;
// Invoke Lambda
client.dispatch_action(
"processing", "acme-corp", "anomaly-detector", "invoke",
serde_json::json!({
"payload": {"device_id": "sensor-001", "value": 92}
}),
).await?;
// Upload to S3
client.dispatch_action(
"archive", "acme-corp", "archive", "put_object",
serde_json::json!({
"key": "data/report.json",
"body": "{\"total\": 42}",
"content_type": "application/json"
}),
).await?;
// Start EC2 instances
client.dispatch_action(
"compute", "acme-corp", "compute", "start_instances",
serde_json::json!({
"instance_ids": ["i-0abc123def456789a"]
}),
).await?;
// Launch new EC2 instances
client.dispatch_action(
"compute", "acme-corp", "compute", "run_instances",
serde_json::json!({
"image_id": "ami-0abcdef1234567890",
"instance_type": "t3.micro",
"max_count": 2,
"tags": {"env": "staging"}
}),
).await?;
// Scale up an Auto Scaling Group
client.dispatch_action(
"scaling", "acme-corp", "scaling", "set_desired_capacity",
serde_json::json!({
"auto_scaling_group_name": "web-tier-asg",
"desired_capacity": 6
}),
).await?;
Ok(())
}
Guardrails for Destructive Operations¶
EC2 operations like terminate_instances and reboot_instances can cause outages when the instances belong to an Auto Scaling Group at or near minimum capacity. Acteon provides several patterns for preventing this, ranging from zero-configuration static rules to client-side orchestration.
Static guardrails (recommended starting point)¶
YAML rules evaluated at high priority can deny destructive operations unless the caller includes an attestation field. This is the simplest approach and works with no external dependencies.
Deny termination/reboot without capacity check:
rules:
- name: require-capacity-check-terminate
priority: 1
description: "Block terminate_instances unless capacity headroom is attested"
condition:
all:
- field: action.action_type
eq: "terminate_instances"
- field: action.payload.capacity_verified
ne: true
action:
type: deny
- name: require-capacity-check-reboot
priority: 1
description: "Block reboot_instances unless capacity headroom is attested"
condition:
all:
- field: action.action_type
eq: "reboot_instances"
- field: action.payload.capacity_verified
ne: true
action:
type: deny
Deny scaling below a threshold:
- name: block-zero-capacity-critical
priority: 2
description: "Prevent scaling critical ASGs to zero"
condition:
all:
- field: action.action_type
eq: "set_desired_capacity"
- field: action.payload.desired_capacity
lt: 1
- field: action.payload.auto_scaling_group_name
ne: "staging-workers"
action:
type: deny
Advantages: zero latency, no external dependencies, full audit trail of blocked operations, works across all providers and action types.
Client-side orchestration (recommended for dynamic checks)¶
Static rules can enforce that a capacity check was performed, but the actual check happens client-side. The pattern is:
- Client calls
describe_auto_scaling_groups(via Acteon or directly) to read current state - Client verifies
desired_capacity > min_size + N(sufficient headroom) - Client dispatches the destructive action with
"capacity_verified": trueand"available_capacity": N - Acteon's static rules allow the action because the attestation is present
Python example:
import acteon
import json
client = acteon.ActeonClient("http://localhost:8080")
# Step 1: Query current ASG state
resp = client.dispatch(
namespace="infra",
tenant="cost-optimizer",
provider="cost-asg",
action_type="describe_auto_scaling_groups",
payload={"auto_scaling_group_names": ["staging-api"]},
)
asg = json.loads(resp.body)["auto_scaling_groups"][0]
# Step 2: Check headroom
desired = asg["desired_capacity"]
min_size = asg["min_size"]
headroom = desired - min_size
if headroom < 2:
print(f"Insufficient headroom ({headroom}), skipping termination")
else:
# Step 3: Dispatch with attestation
client.dispatch(
namespace="infra",
tenant="cost-optimizer",
provider="cost-ec2",
action_type="terminate_instances",
payload={
"instance_ids": ["i-0abc123def456789a"],
"capacity_verified": True,
"available_capacity": headroom,
},
)
Chain-based pre-flight (partial support)¶
Action chains can sequence a describe step before a destructive step:
Step 1: describe_auto_scaling_groups → get current state
Step 2: terminate_instances (uses result from step 1)
The describe result is available in step 2 via {{steps.step1.body.auto_scaling_groups}}. With numeric branch operators (gt, lt, gte, lte), chains can conditionally abort based on capacity arithmetic:
[[chains.definitions]]
name = "safe-terminate"
[[chains.definitions.steps]]
name = "check-capacity"
provider = "cost-asg"
action_type = "describe_auto_scaling_groups"
payload = { auto_scaling_group_names = ["staging-api"] }
[[chains.definitions.steps.branches]]
field = "body.auto_scaling_groups.0.desired_capacity"
operator = "lte"
value = 2
target = "abort"
[[chains.definitions.steps]]
name = "terminate"
provider = "cost-ec2"
action_type = "terminate_instances"
payload = { instance_ids = ["i-0abc123"] }
[[chains.definitions.steps]]
name = "abort"
provider = "log"
action_type = "send_alert"
payload = { message = "Insufficient capacity, termination blocked" }
Pre-dispatch enrichment (live state in rules)¶
Pre-dispatch enrichment lets the gateway fetch live resource state before rule evaluation, so rules can check real-time data instead of relying on client-side attestation.
[[enrichments]]
name = "fetch-asg-state"
action_type = "terminate_instances"
provider = "cost-ec2"
lookup_provider = "cost-asg"
resource_type = "auto_scaling_group"
merge_key = "current_asg_state"
timeout_seconds = 10
failure_policy = "fail_closed"
[enrichments.params]
auto_scaling_group_names = ["{{payload.asg_name}}"]
When configured, the gateway automatically:
- Matches the enrichment filter against the incoming action (namespace, tenant, action_type, provider)
- Resolves
{{payload.X}}placeholders in the params template - Calls the
ResourceLookupimplementation on the lookup provider - Merges the result into
action.payloadunder the configuredmerge_key
Rules can then check action.payload.current_asg_state.auto_scaling_groups.0.desired_capacity to enforce capacity thresholds on live data.
The failure_policy controls behavior when the lookup fails: - fail_open (default) -- continue dispatch without the enrichment data - fail_closed -- reject the dispatch entirely
EC2 and Auto Scaling providers automatically register as ResourceLookup implementations, supporting "instance" and "auto_scaling_group" resource types respectively.
Limitations and future direction¶
| Limitation | Why | Workaround |
|---|---|---|
| WASM plugins can't query AWS | Sandboxed with no network access | Use WASM for payload validation logic only |
| Enrichment adds latency | Lookup call happens before rule evaluation | Use timeout_seconds and fail_open to bound impact |
Future enhancements: - Optional WASM network grants for trusted plugins - Additional ResourceLookup implementations for GCP, Azure
See the AWS Cost Optimizer example for a runnable demo of static guardrails with the capacity_verified attestation pattern.
LocalStack Development¶
All AWS providers support endpoint URL overrides, making it easy to develop and test locally with LocalStack:
Point every provider at http://localhost:4566:
[[providers]]
name = "alert-fanout"
type = "aws-sns"
aws_region = "us-east-1"
aws_endpoint_url = "http://localhost:4566"
topic_arn = "arn:aws:sns:us-east-1:000000000000:alerts"
See the AWS Event-Driven Pipeline Guide for a complete runnable example using LocalStack with all eight provider types.