Terraform
These are customer-side patterns: infrastructure you run in your own AWS account to integrate with ConstellationOS. There is no Terraform provider for Constellation, so Terraform cannot provision Constellation-side resources (tenants, API keys, data planes); those are provisioned during onboarding, and this page covers your side of the integration only.
Three patterns, composed into one applyable module:
- Store the tenant API key in AWS Secrets Manager.
- A scheduled ingestion job: EventBridge Scheduler invokes a Lambda that POSTs telemetry, reading the key from Secrets Manager at runtime.
- Health monitoring: a scheduled Lambda probes the
/healthendpoints (they require no auth) and a CloudWatch alarm fires when probes fail. This is the do-it-yourself alternative to a CloudWatch Synthetics canary; Synthetics works too if you prefer the managed option.
versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
archive = {
source = "hashicorp/archive"
version = ">= 2.4"
}
}
}
variables.tf
variable "api_url" {
description = "ConstellationOS base URL"
type = string
default = "https://api.constellation.space"
}
variable "api_key" {
description = "Tenant API key, stored in Secrets Manager. Pass via TF_VAR_api_key, never commit it."
type = string
sensitive = true
}
variable "secret_name" {
description = "Secrets Manager secret name for the tenant API key"
type = string
default = "constellation/tenant-api-key"
}
variable "ingest_schedule" {
description = "EventBridge Scheduler expression for the telemetry ingester"
type = string
default = "rate(5 minutes)"
}
variable "health_schedule" {
description = "EventBridge Scheduler expression for the health probe"
type = string
default = "rate(1 minute)"
}
variable "alarm_actions" {
description = "ARNs (for example an SNS topic) notified when the health alarm fires"
type = list(string)
default = []
}
secrets.tf
resource "aws_secretsmanager_secret" "tenant_api_key" {
name = var.secret_name
description = "ConstellationOS tenant API key (x-api-key header value)"
}
resource "aws_secretsmanager_secret_version" "tenant_api_key" {
secret_id = aws_secretsmanager_secret.tenant_api_key.id
secret_string = var.api_key
}
ingester.tf
The Lambda reads the key from Secrets Manager at runtime (cached across warm invocations), builds a record array, and POSTs it to /telemetry. Replace collect_records with your real telemetry source. Keep each run under the server caps: 1000 records and 1 MB per request. A partial rejection raises, so it lands in the Lambda Errors metric where you can alarm on it.
data "archive_file" "ingester" {
type = "zip"
output_path = "${path.module}/build/ingester.zip"
source {
filename = "index.py"
content = <<-PY
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(f"partial rejection: {ack}")
print(json.dumps(ack))
return ack
PY
}
}
resource "aws_lambda_function" "ingester" {
function_name = "constellation-telemetry-ingester"
role = aws_iam_role.ingester.arn
handler = "index.handler"
runtime = "python3.12"
timeout = 60
filename = data.archive_file.ingester.output_path
source_code_hash = data.archive_file.ingester.output_base64sha256
environment {
variables = {
CONSTELLATION_API_URL = var.api_url
API_KEY_SECRET_ARN = aws_secretsmanager_secret.tenant_api_key.arn
}
}
}
resource "aws_scheduler_schedule" "ingest" {
name = "constellation-telemetry-ingest"
schedule_expression = var.ingest_schedule
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_lambda_function.ingester.arn
role_arn = aws_iam_role.scheduler.arn
}
}
health.tf
The probe hits /health plus all three subsystem probes. Any non-200 (a subsystem returns 503 when degraded) raises, so failures surface in the Lambda Errors metric and trip the alarm.
data "archive_file" "health" {
type = "zip"
output_path = "${path.module}/build/health.zip"
source {
filename = "index.py"
content = <<-PY
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(f"{path}: HTTP {resp.status}")
except urllib.error.HTTPError as err:
failures.append(f"{path}: HTTP {err.code}")
except urllib.error.URLError as err:
failures.append(f"{path}: {err.reason}")
if failures:
raise RuntimeError("; ".join(failures))
return {"ok": True}
PY
}
}
resource "aws_lambda_function" "health" {
function_name = "constellation-health-probe"
role = aws_iam_role.health.arn
handler = "index.handler"
runtime = "python3.12"
timeout = 50
filename = data.archive_file.health.output_path
source_code_hash = data.archive_file.health.output_base64sha256
environment {
variables = {
CONSTELLATION_API_URL = var.api_url
}
}
}
resource "aws_scheduler_schedule" "health" {
name = "constellation-health-probe"
schedule_expression = var.health_schedule
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_lambda_function.health.arn
role_arn = aws_iam_role.scheduler.arn
}
}
resource "aws_cloudwatch_metric_alarm" "health" {
alarm_name = "constellation-api-health"
alarm_description = "ConstellationOS health probes failing (or probe not running)"
namespace = "AWS/Lambda"
metric_name = "Errors"
statistic = "Sum"
period = 60
evaluation_periods = 3
datapoints_to_alarm = 2
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "breaching"
alarm_actions = var.alarm_actions
ok_actions = var.alarm_actions
dimensions = {
FunctionName = aws_lambda_function.health.function_name
}
}
treat_missing_data = "breaching" makes the alarm fire if the probe itself stops running, so a broken scheduler cannot silently mask an outage. Pair health_schedule with the alarm math: at rate(1 minute) the Lambda emits one datapoint per 60-second period, and 2 failing datapoints out of 3 periods trips the alarm.
iam.tf
Least privilege: the ingester can read exactly one secret, the health probe can only write logs, and the scheduler role can invoke exactly these two functions. Neither Lambda role can invoke anything, touch other secrets, or reach other AWS APIs.
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "scheduler_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ingester" {
name = "constellation-telemetry-ingester"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy" "ingester" {
name = "ingester"
role = aws_iam_role.ingester.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:log-group:/aws/lambda/constellation-telemetry-ingester*"
},
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = aws_secretsmanager_secret.tenant_api_key.arn
}
]
})
}
resource "aws_iam_role" "health" {
name = "constellation-health-probe"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy" "health" {
name = "health"
role = aws_iam_role.health.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:log-group:/aws/lambda/constellation-health-probe*"
}
]
})
}
resource "aws_iam_role" "scheduler" {
name = "constellation-scheduler"
assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
}
resource "aws_iam_role_policy" "scheduler" {
name = "invoke"
role = aws_iam_role.scheduler.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["lambda:InvokeFunction"]
Resource = [
aws_lambda_function.ingester.arn,
aws_lambda_function.health.arn,
]
}
]
})
}
Applying
export TF_VAR_api_key='<your tenant key>'
terraform init
terraform plan
terraform apply
Notes
- EventBridge Scheduler invokes Lambdas through its execution role, so no Lambda resource-based permission is needed (unlike classic EventBridge rules).
- Rotating the key is a Secrets Manager write plus nothing else: the ingester reads the secret at runtime. If you rotate frequently, drop the warm-invocation cache in the Lambda.
- Mind the API rate limits when choosing schedules: per-IP 30 requests/min and per-tenant 60 requests/min. The defaults here (health every minute makes 4 calls, ingest every 5 minutes makes 1) sit comfortably under both.
- To alert on ingestion failures too, clone the alarm with
FunctionName = aws_lambda_function.ingester.function_name.