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.cloud is aws by default or azure, fixed at creation. The composition creates every supporting piece and deletes all of them with the XR.DependencyReady, with a fixed set of reasons.A durable queue with a dead-letter queue. On AWS it can trigger a Lambda function.
A private, encrypted bucket for files and blobs, with access for Lambda functions.
One PostgreSQL database, TLS only, reachable only from the networks you list.
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.
| Kind | On AWS | On Azure |
|---|---|---|
| MessageQueue |
|
|
| ObjectStorage |
|
|
| ManagedPostgreSQL |
|
|
Key fields:
queueName, fifo, lockSeconds, retentionSeconds, deadLetter, consumers[]bucketName, versioning, emptyOnDelete, consumers[]size, version, storageGiB, databaseName, allowedCidrs, protectNone of these has a field to turn it off. That's deliberate: the absence of the field is the policy.
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.
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.
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.
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.
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.
| Kind | access | Actions, on this queue or bucket only |
|---|---|---|
| MessageQueue | send | SendMessage |
receive | ReceiveMessage, DeleteMessage, ChangeMessageVisibility | |
sendReceive | Both of the above (the default). Every queue grant also includes GetQueueAttributes and GetQueueUrl. | |
| ObjectStorage | read | ListBucket, GetBucketLocation, GetObject |
readWrite | Also PutObject, DeleteObject and multipart uploads (the default) |
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.
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.
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.
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.
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.
| Reason | State | Means |
|---|---|---|
…Ready | Ready | Every piece is ready. The connection details are in the ConfigMap. |
…Provisioning | Working | Something is still being created. The message names what is pending. |
…ConsumerUnresolved | Attention | Queue or storage only. It's ready, but a consumers[] entry names no target here, or one in another region. |
MessageQueueNeedsAwsAccount | Attention | The cluster registry names no AWS account, so the queue's ARN is unknown and nothing is created. |
ManagedPostgreSQLFailing | Failing | The provider can't create the server: a taken name, a quota, an unavailable instance class. The message carries its error. |
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.
| Check | Result | What I saw |
|---|---|---|
| Ready | Pass | Queue, bucket and Lambda target ready in about 4 minutes, the database in about 7. airframe-verify passed every step for each kind. |
| Grants | Pass | The IAM policy simulator, boundary included, allowed this queue and this bucket, and denied any other. |
| Trigger | Pass | One 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. |
| Bucket | Pass | All four public-access blocks on, ownership enforced. An object written and read back was encrypted with AES256. |
| Database | Pass | PostgreSQL 17.9 on db.t4g.micro, encrypted, admitting one /32. psql connected over TLS 1.3. A connection with sslmode=disable was refused. |
| Teardown | Pass | Removing 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. |
Airframe's known gaps 8 to 10, written down rather than discovered later.
An ECS service has no task role for app code yet. A Kubernetes app has no cloud identity. Azure consumers use the connection string.
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.
The Azure compositions are render-tested and schema-checked, but their three providers aren't installed on the dev cluster yet.