Airframe · Spec · Cloud dependencies

Managed cloud services an app can depend on, on AWS or Azure

Until now an app on a cloud target could run in the cloud, but whatever it depended on had to come from somewhere else. Three new kinds in catalog.hangar.io/v1alpha1 close that: a queue, object storage and a PostgreSQL database, each run by the cloud provider. You ask for one the way you ask for a cloud target, with a manifest in the app's xr-requests/, and you delete it the same way. One field picks the cloud. Everything else means the same thing on both.

Spec October 24, 2026Status AWS verified live, Oct 24, 2026; Azure render-tested, never runVersion Airframe v0.3.165 · airframe#123, #124, #125Docs Cloud dependencies · Airframe cloud credentials
AWS verified live · Oct 24, 2026

A queue, a bucket and a database, asked for in git, with the guardrails already on.

Three kinds

Amazon SQS · Service Bus

MessageQueue

A durable queue with a dead-letter queue. On AWS it can trigger a Lambda function.

Amazon S3 · Storage Account

ObjectStorage

A private, encrypted bucket for files and blobs, with access for Lambda functions.

Amazon RDS · Flexible Server

ManagedPostgreSQL

One PostgreSQL database, TLS only, reachable only from the networks you list.

One request, the right pieces on each cloud

spec.cloud is aws by default and can't change after creation. The composition creates every supporting piece, and deletes all of them with the XR.

KindOn AWSOn Azure
MessageQueue
  • SQS queue, server-side encryption with SQS-owned keys
  • Dead-letter queue <name>-dlq, kept 14 days
  • Per consumer: an IAM grant, and an event source mapping if it's a trigger
  • Resource group, owned by the XR
  • Service Bus namespace (Basic) and queue
  • A listen and send policy for this queue only
ObjectStorage
  • S3 bucket
  • Block Public Access, ownership enforced, SSE-S3
  • Optional versioning
  • Per consumer: an IAM grant
  • Resource group, owned by the XR
  • StorageV2 account, TLS 1.2, HTTPS only
  • One private blob container
ManagedPostgreSQL
  • Its own VPC, two subnets in two zones
  • Security group, one rule per allowed CIDR
  • RDS for PostgreSQL 16 or 17, encrypted
  • Resource group, owned by the XR
  • Flexible Server and its database
  • One firewall rule per allowed CIDR

Key fields:

Guardrails you get without asking

None of these has a field to turn it off. That's deliberate: the absence of the field is the policy.

All kinds

Private by default

No field makes a bucket public. All four S3 public-access blocks are on and ACLs are disabled. An Azure account allows no public blobs. A database admits no network until allowedCidrs lists one.

All kinds

Encrypted, TLS only

Queues, buckets and database storage are encrypted at rest. PostgreSQL refuses plaintext connections on both clouds. Storage accepts only HTTPS with TLS 1.2 or later.

Consumers

No access keys on AWS

A Lambda consumer gets an inline policy on its own execution role for this one queue or bucket. The HangarCloudTargetBoundary permissions boundary caps every grant.

Deletion

No accidental data loss

A bucket that holds objects is kept unless emptyOnDelete is set. Azure always deletes contents, so it has to say so. protect adds RDS deletion protection and a final snapshot.

How a Lambda function gets access

List an AwsLambdaTarget in consumers[]. The composition looks it up in the same namespace and writes a scoped policy onto that function's execution role. With trigger: true it also connects the queue to the function.

Grants and triggers, not keysOn the dev cluster, a MessageQueue XR named baggage-scans lists the AwsLambdaTarget baggage-api as a consumer. The composition looks the target up, creates an encrypted SQS queue with a dead-letter queue in AWS, connects the queue to the Lambda function with an event source mapping, and writes an inline policy for this queue only onto the function's execution role, inside the permissions boundary. Messages that fail N times move to the dead-letter queue.DEV CLUSTER · APP-BAGGAGE-API-CICDAWS ACCOUNT · ONE REGIONPERMISSIONS BOUNDARYCREATESTRIGGERSAFTER N TRIESRUNS ASLOOKS UPA GRANT ON ITS ROLE, NOT A KEYMessageQueuebaggage-scans · the XRAwsLambdaTargetbaggage-apiSQS queueencrypted · redriveDead-letter queuebaggage-scans-dlqLambda functioninvoked with batchesbaggage-api-execinline policy · this queue
Figure 1ObjectStorage works the same way, without the trigger. Removing a consumer removes its grant and its trigger. A consumer that names a target that doesn't exist, or one in another region, gets nothing, and the status says which.
KindaccessActions, on this queue or bucket only
MessageQueuesendSendMessage
receiveReceiveMessage, DeleteMessage, ChangeMessageVisibility
sendReceiveBoth of the above (the default). Every queue grant also includes GetQueueAttributes and GetQueueUrl.
ObjectStoragereadListBucket, GetBucketLocation, GetObject
readWriteAlso PutObject, DeleteObject and multipart uploads (the default)

Request one

Put the manifest in tenants/<app>/xr-requests/ of the dev cluster's tenants repo. It lands in app-<app>-cicd on the dev cluster, where the cloud providers run. These are not components: entries. Here is Skyport's baggage-api asking for all three.

apiVersion: catalog.hangar.io/v1alpha1
kind: MessageQueue
metadata:
  name: baggage-scans
spec:
  lockSeconds: 60              # at least the function's timeout, for a trigger
  deadLetter:
    maxReceiveCount: 5         # then the message moves to baggage-scans-dlq
  consumers:
    - awsLambdaTarget: baggage-api   # an AwsLambdaTarget in the same namespace
      access: receive
      trigger: true                  # the queue invokes the function
      batchSize: 10
---
apiVersion: catalog.hangar.io/v1alpha1
kind: ObjectStorage
metadata:
  name: baggage-exports
spec:
  bucketName: skyport-baggage-exports   # S3 names are global across AWS
  versioning: true
  consumers:
    - awsLambdaTarget: baggage-api
      access: readWrite
---
apiVersion: catalog.hangar.io/v1alpha1
kind: ManagedPostgreSQL
metadata:
  name: baggage-db
spec:
  size: small                 # db.t4g.micro on AWS, B_Standard_B1ms on Azure
  version: "17"
  storageGiB: 32
  allowedCidrs:               # empty admits no one
    - 203.0.113.0/24
  protect: true               # deletion protection + final snapshot (AWS)

On Azure the same ObjectStorage says cloud: azure, a location, and emptyOnDelete: true, which Azure requires because it deletes an account with its contents. It creates a resource group, a storage account and one private container, and consumers read the connection string from the <name>-credentials Secret.

What an app reads

Two objects, with the same names on every kind, both in the XR's own namespace. The XR's status repeats the non-secret values.

ConfigMap

<name>-connection

Everything that isn't secret, written by the composition. A queue's name and endpoint (plus its ARN, region and dead-letter URL on AWS); a bucket's name and endpoint; a database's host, port, database, username and sslmode.

Secret

<name>-credentials

Written by the cloud provider, never by the composition, and never in git. The database password, generated by the provider; on Azure, the queue's or account's connection string. On AWS a queue or bucket has no Secret at all: access is the consumer's role.

DependencyReady, with a fixed set of reasons

Each kind reports one condition of its own beside Crossplane's Ready and Synced. Each reason has exactly one meaning, so a script or an agent can branch on it.

ReasonStateMeans
…ReadyReadyEvery piece is ready. The connection details are in the ConfigMap.
…ProvisioningWorkingSomething is still being created. The message names what is pending.
…ConsumerUnresolvedAttentionQueue or storage only. It's ready, but a consumers[] entry names no target here, or one in another region.
MessageQueueNeedsAwsAccountAttentionThe cluster registry names no AWS account, so the queue's ARN is unknown and nothing is created.
ManagedPostgreSQLFailingFailingThe provider can't create the server: a taken name, a quota, an unavailable instance class. The message carries its error.
The life of a cloud dependencyFive steps: commit the XR into the app's xr-requests folder; the composition creates every piece; DependencyReady reports with a fixed set of reasons; the app reads a ConfigMap and, where there is one, a Secret, both named after the XR; deleting the file removes all of it.Commit the XRxr-requests/ in gitCompositioncreates every pieceDependencyReadya fixed set of reasonsApp reads ita ConfigMap, a SecretDelete the fileremoves all of itA queue or a bucket is ready in under a minute. A new database takes 5 to 15 minutes.A bucket with objects in it is kept unless emptyOnDelete says otherwise; protect adds RDS deletion protection.
Figure 2The same lifecycle as every other Airframe request, so a person, a Backstage form or an agent asks for a database exactly the way it asks for a function.

What was verified

On October 24, 2026, on the dev cluster against Hangar's AWS account, a throwaway Lambda target and one of each kind were requested through the tenants repo, checked, then removed the same way.

CheckResultWhat I saw
ReadyPassQueue, bucket and Lambda target ready in about 4 minutes, the database in about 7. airframe-verify passed every step for each kind.
GrantsPassThe IAM policy simulator, boundary included, allowed this queue and this bucket, and denied any other.
TriggerPassOne message invoked the function twice, matching maxReceiveCount: 2. Both runs failed on purpose, because the placeholder image has no handler, and the message moved to the dead-letter queue.
BucketPassAll four public-access blocks on, ownership enforced. An object written and read back was encrypted with AES256.
DatabasePassPostgreSQL 17.9 on db.t4g.micro, encrypted, admitting one /32. psql connected over TLS 1.3. A connection with sslmode=disable was refused.
TeardownPassRemoving the file deleted the queues, the bucket and its object, the instance, the VPC, the function, the repository, the roles and the password Secret. No snapshot was left.

Limits to plan around

Airframe's known gaps 8 to 10, written down rather than discovered later.

Gap 8

Lambda is the only consumer

An ECS service has no task role for app code yet. A Kubernetes app has no cloud identity. Azure consumers use the connection string.

Gap 9

The database is public-endpoint only

A Lambda function outside a VPC has no fixed address, so reaching the database means 0.0.0.0/0, with the password and TLS as the protection. There is no separate app role yet.

Gap 10

Azure hasn't run

The Azure compositions are render-tested and schema-checked, but their three providers aren't installed on the dev cluster yet.