Skip to main content

CloudFormation

The same two customer-side patterns as the Terraform page, packaged as one CloudFormation template: the tenant key in Secrets Manager, an EventBridge Scheduler driven telemetry ingester, and a health-probe Lambda with a CloudWatch alarm. As with Terraform, there is no way to provision Constellation-side resources from CloudFormation; this template covers only your side of the integration.

Deploy:

aws cloudformation deploy \
--template-file constellation-integration.yaml \
--stack-name constellation-integration \
--capabilities CAPABILITY_IAM \
--parameter-overrides ApiKey='<your tenant key>' AlarmEmail=ops@example.com

The template (constellation-integration.yaml):

AWSTemplateFormatVersion: "2010-09-09"
Description: >
Customer-side ConstellationOS integration: tenant API key in Secrets Manager,
a scheduled telemetry ingester, and health monitoring with a CloudWatch alarm.

Parameters:
ApiUrl:
Type: String
Default: https://api.constellation.space
Description: ConstellationOS base URL
ApiKey:
Type: String
NoEcho: true
Description: Tenant API key (stored in Secrets Manager, never echoed)
SecretName:
Type: String
Default: constellation/tenant-api-key
Description: Secrets Manager secret name for the tenant API key
IngestScheduleExpression:
Type: String
Default: rate(5 minutes)
Description: EventBridge Scheduler expression for the telemetry ingester
HealthScheduleExpression:
Type: String
Default: rate(1 minute)
Description: EventBridge Scheduler expression for the health probe
AlarmEmail:
Type: String
Default: ""
Description: Optional email for health alarm notifications (empty disables notifications)

Conditions:
HasAlarmEmail: !Not [!Equals [!Ref AlarmEmail, ""]]

Resources:
TenantApiKeySecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Ref SecretName
Description: ConstellationOS tenant API key (x-api-key header value)
SecretString: !Ref ApiKey

IngesterRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: ingester
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/constellation-telemetry-ingester*
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Ref TenantApiKeySecret

IngesterFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: constellation-telemetry-ingester
Runtime: python3.12
Handler: index.handler
Timeout: 60
Role: !GetAtt IngesterRole.Arn
Environment:
Variables:
CONSTELLATION_API_URL: !Ref ApiUrl
API_KEY_SECRET_ARN: !Ref TenantApiKeySecret
Code:
ZipFile: |
import json
import os
import urllib.request
from datetime import datetime, timezone

import boto3

API_URL = os.environ["CONSTELLATION_API_URL"].rstrip("/")
SECRET_ARN = os.environ["API_KEY_SECRET_ARN"]

_secrets = boto3.client("secretsmanager")
_api_key = None


def get_key():
global _api_key
if _api_key is None:
_api_key = _secrets.get_secret_value(SecretId=SECRET_ARN)["SecretString"]
return _api_key


def collect_records():
# Replace with your real telemetry source. Stay under the server
# caps: 1000 records and 1 MB per request.
now = datetime.now(timezone.utc).isoformat()
return [
{
"measurement": "ground_station",
"time": now,
"tags": {"entity_id": "gs-example"},
"fields": {"snr_db": 12.4},
}
]


def handler(event, context):
records = collect_records()
if not records:
return {"skipped": True}
req = urllib.request.Request(
API_URL + "/telemetry",
data=json.dumps(records).encode(),
headers={"x-api-key": get_key(), "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
ack = json.loads(resp.read())
if ack.get("rejected_count", 0):
raise RuntimeError("partial rejection: " + json.dumps(ack))
print(json.dumps(ack))
return ack

HealthRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: health
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/constellation-health-probe*

HealthFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: constellation-health-probe
Runtime: python3.12
Handler: index.handler
Timeout: 50
Role: !GetAtt HealthRole.Arn
Environment:
Variables:
CONSTELLATION_API_URL: !Ref ApiUrl
Code:
ZipFile: |
import os
import urllib.error
import urllib.request

API_URL = os.environ["CONSTELLATION_API_URL"].rstrip("/")
PATHS = ["/health", "/health/telemetry", "/health/topology", "/health/predictions"]


def handler(event, context):
failures = []
for path in PATHS:
try:
with urllib.request.urlopen(API_URL + path, timeout=10) as resp:
if resp.status != 200:
failures.append(path + ": HTTP " + str(resp.status))
except urllib.error.HTTPError as err:
failures.append(path + ": HTTP " + str(err.code))
except urllib.error.URLError as err:
failures.append(path + ": " + str(err.reason))
if failures:
raise RuntimeError("; ".join(failures))
return {"ok": True}

SchedulerRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: scheduler.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: invoke
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource:
- !GetAtt IngesterFunction.Arn
- !GetAtt HealthFunction.Arn

IngestSchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: constellation-telemetry-ingest
ScheduleExpression: !Ref IngestScheduleExpression
FlexibleTimeWindow:
Mode: "OFF"
Target:
Arn: !GetAtt IngesterFunction.Arn
RoleArn: !GetAtt SchedulerRole.Arn

HealthSchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: constellation-health-probe
ScheduleExpression: !Ref HealthScheduleExpression
FlexibleTimeWindow:
Mode: "OFF"
Target:
Arn: !GetAtt HealthFunction.Arn
RoleArn: !GetAtt SchedulerRole.Arn

AlarmTopic:
Type: AWS::SNS::Topic
Condition: HasAlarmEmail
Properties:
Subscription:
- Protocol: email
Endpoint: !Ref AlarmEmail

HealthAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: constellation-api-health
AlarmDescription: ConstellationOS health probes failing (or probe not running)
Namespace: AWS/Lambda
MetricName: Errors
Statistic: Sum
Period: 60
EvaluationPeriods: 3
DatapointsToAlarm: 2
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: breaching
Dimensions:
- Name: FunctionName
Value: !Ref HealthFunction
AlarmActions: !If [HasAlarmEmail, [!Ref AlarmTopic], !Ref "AWS::NoValue"]
OKActions: !If [HasAlarmEmail, [!Ref AlarmTopic], !Ref "AWS::NoValue"]

Outputs:
SecretArn:
Description: ARN of the tenant API key secret
Value: !Ref TenantApiKeySecret
IngesterFunctionName:
Description: Telemetry ingester Lambda
Value: !Ref IngesterFunction
HealthFunctionName:
Description: Health probe Lambda
Value: !Ref HealthFunction

Notes

  • FlexibleTimeWindow.Mode is quoted as "OFF" deliberately; unquoted OFF parses as a YAML boolean and breaks the deploy.
  • TreatMissingData: breaching makes the alarm fire if the probe stops running, so a broken schedule cannot silently mask an outage.
  • The /health* probes need no API key; the ingester reads the key from Secrets Manager at runtime, so rotation is a secret update with no redeploy.
  • Function names are fixed (constellation-telemetry-ingester, constellation-health-probe) so the IAM log policies can stay narrow. Do not deploy this template and the Terraform module in the same account and region without renaming one set.
  • Schedules and rate limits: the defaults make 4 health calls per minute and 1 ingest call per 5 minutes, well under the per-IP 30/min and per-tenant 60/min limits.