Skip to content

Cloud Connector

Cloud Connector provisions and manages cloud infrastructure across multiple providers. Provisioning happens through direct provider SDK/REST calls, not Infrastructure-as-Code — an earlier CDKTF (Terraform CDK) design was fully removed (ADR-001/M10, dependency cleanup SEC-231). There is no cdktf/constructs dependency in the codebase anymore.

  • Port: 4321 (API server only — the worker process has no HTTP surface)
  • Tech: Express 4, TypeScript, TypeORM + PostgreSQL, amqplib (RabbitMQ), Winston, Axios
  • Database: PostgreSQL (cloud-connector-db, dev port 5434)
  • Messaging: RabbitMQ, cloud.events exchange (topic) — single canonical exchange per ADR-001
  • Provisioning: Always asynchronous — never a synchronous call into a provider API from an HTTP request path

Adapters live in src/adapters/*.adapter.ts and implement the ICloudProvider interface.

ProviderStatusAdapter file(s)
AWSFully implementedaws.adapter.ts, aws-iam.adapter.ts, aws-network.adapter.ts
Google CloudFully implementedgcp.adapter.ts, gcp-iam.adapter.ts
AzureFully implementedazure.adapter.ts, azure-iam.adapter.ts, azure-network.adapter.ts (~1256 lines in the main adapter alone)
HetznerFully implementedhetzner.adapter.ts
ScalewayFully implementedscaleway.adapter.ts
OVHcloudFully implementedovhcloud.adapter.ts
UpCloudFully implementedupcloud.adapter.ts
DigitalOceanNot yet implemented — no adapter file exists in src/adapters/

Azure was historically listed as “in progress (BYOC)” in older notes; that is stale — the Azure adapter set is complete and on par with AWS/GCP. DigitalOcean, by contrast, has no adapter at all yet, despite appearing as a supported provider value in the job schema (see below) — treat it as reserved/planned, not working.

The canonical path (ADR-001) has no Terraform/CDKTF step and no HTTP bridge service:

Strapi: cloud-instance lifecycle hook (create/update/destroy)
webhook-publisher (wraps event as { event, data: { jobId, type, cloudInstanceId, credential_id?, payload: { provider, region, config } } })
RabbitMQ: cloud.events exchange (topic)
routing key: cloud-instance.provision | cloud-instance.update | cloud-instance.destroy
cloud-connector-worker (dist/worker.js, separate Docker Compose service — consumes directly, no HTTP hop)
worker.ts unwraps the webhook-publisher envelope, resolves the BYOC credential
Provider adapter (direct SDK/REST call — AWS SDK, @google-cloud, Azure SDK, Hetzner REST, …)
POST /api/cloud-instances/:documentId/status-callback (Strapi)
header: X-Service-Secret: CLOUD_CALLBACK_SECRET
Strapi updates CloudInstance status → frontend sees the new state

There is also an admin/sync path: POST /v1/provision on the API server accepts a provisioning request directly and publishes onto the same cloud.events exchange rather than doing any work inline.

Directory: src/adapters/

src/adapters/
├── aws.adapter.ts
├── aws-iam.adapter.ts
├── aws-network.adapter.ts
├── azure.adapter.ts
├── azure-iam.adapter.ts
├── azure-network.adapter.ts
├── base-resource.adapter.ts
├── gcp.adapter.ts
├── gcp-iam.adapter.ts
├── hetzner.adapter.ts
├── multi-region.adapter.ts
├── ovhcloud.adapter.ts
├── scaleway.adapter.ts
├── tier-aware-adapter-factory.ts
└── upcloud.adapter.ts

Each provider adapter:

  • Implements the ICloudProvider interface
  • Calls the provider’s SDK/REST API directly (no generated IaC, no terraform apply)
  • Returns instance details (IP, SSH key/credentials, provider-native instance ID)

Schema is owned by TypeORM migrations under src/database/migrations/ (there is currently no src/database/entities/ directory in the repository — the job/instance state is modeled in src/models/job.model.ts and applied via migration, e.g. CreateJobsTable). The baseline jobs table tracks provisioning jobs:

jobs
├── id (uuid)
├── type (enum: provision | update | destroy | heartbeat)
├── provider (enum: aws | gcp | azure | digitalocean | scaleway | ovhcloud | hetzner | upcloud)
├── region
├── config (jsonb)
├── status (enum: PENDING | RUNNING | SUCCESS | FAILED)
├── createdAt / updatedAt
├── startedAt / completedAt
├── error (text)
├── result (jsonb)
├── retryCount / maxRetries
├── instanceId
└── metadata (jsonb)

Confirm exact column/table names against src/database/migrations/ and src/models/ directly before relying on them in code — this reflects the current baseline migration, not a hand-maintained entity file.

Do not hand-edit applied migrations — add a new migration file for schema changes.

.env variables (excerpt):

Terminal window
# API server
PORT=4321
DATABASE_HOST=cloud-connector-db
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
STRAPI_BASE_URL=http://backend:1337
STRAPI_API_TOKEN=<token>
CLOUD_CALLBACK_SECRET=<shared secret for status-callback>
DEPLOYMENT_STRATEGY=single-region
# AWS
AWS_REGION=eu-central-1
AWS_ACCESS_KEY_ID=<key>
AWS_SECRET_ACCESS_KEY=<secret>
# Google Cloud
GCP_PROJECT_ID=<project>
GOOGLE_APPLICATION_CREDENTIALS=/data/gcp-key.json
# Azure
AZURE_SUBSCRIPTION_ID=<id>
AZURE_TENANT_ID=<id>
AZURE_CLIENT_ID=<id>
AZURE_CLIENT_SECRET=<secret>
# Hetzner
HETZNER_TOKEN=<token>
# Scaleway / OVHcloud / UpCloud
SCALEWAY_ACCESS_KEY=<key>
SCALEWAY_SECRET_KEY=<secret>
OVH_APPLICATION_KEY=<key>
OVH_APPLICATION_SECRET=<secret>
UPCLOUD_USERNAME=<username>
UPCLOUD_PASSWORD=<password>

Most production credentials are supplied per-tenant as BYOC (bring-your-own-cloud) credentials rather than global .env values — see the BYOC credential material in cloud-connector/CLAUDE.md for the resolution order.

The API server (src/index.ts) exposes a larger set of routes than documented here (DNS, CDN, WAF, monitoring, deployment-switch endpoints among them); the following are the ones relevant to the provisioning/Strapi integration:

Terminal window
GET /health # Health check
GET /v1/providers/health # Per-provider health status
POST /v1/provision # Accept a provisioning job, publish to cloud.events
GET /v1/jobs/:jobId # Job status lookup
GET /v1/jobs/stats # Aggregate job stats
GET /v1/strapi/status # Full status for the Strapi dashboard
GET /v1/strapi/health # Health check tailored for Strapi
POST /v1/strapi/webhook # Inbound webhook from Strapi
POST /v1/strapi/force-update # Force a status update
POST /v1/strapi/cleanup # Clean up stale jobs

Confirm the full route list against src/index.ts — additional endpoints exist for DNS zones, CDN distributions, WAF ACLs, DDoS resource tracking, and blue/green deployment switching that are out of scope for this page.

Cloud Connector consumes from the cloud.events exchange (topic, single canonical exchange per ADR-001):

Routing keyQueueAction
cloud-instance.provisioncloud.connector.provisionProvision a new instance
cloud-instance.updatecloud.connector.updateUpdate an existing instance
cloud-instance.destroycloud.connector.destroyDestroy an instance
inventory.scancloud.connector.inventory-scanRead-only resource discovery (adapter.discoverResources()), batched back to /api/cloud-assets/scan-callback

Failed jobs are dead-lettered rather than dropped: exchange cloud.connector.dead (topic) + queue cloud.connector.dead.q, with a 14-day TTL.

Job payload shape (unwrapped from the webhook-publisher envelope by worker.ts):

{
"jobId": "uuid",
"type": "provision",
"cloudInstanceId": "documentId",
"credential_id": "optional-byoc-credential-id",
"payload": {
"provider": "aws",
"region": "eu-central-1",
"config": { "instanceType": "medium", "os": "ubuntu" }
}
}
  1. Create the adapter: src/adapters/<provider>.adapter.ts
export class MyProviderAdapter implements ICloudProvider {
async provision(config: InstanceConfig): Promise<InstanceDetails> {
// Direct SDK/REST call to the provider — no generated IaC
// Return { ip, sshKey, credentials, ... }
}
async update(instanceId: string, config: Partial<InstanceConfig>): Promise<void> { /* ... */ }
async destroy(instanceId: string): Promise<void> { /* ... */ }
}
  1. Register the adapter in the provider factory (src/adapters/tier-aware-adapter-factory.ts or the relevant config module).
  2. Add env vars to .env.example and orchestration/.env.example.
  3. Add the provider value to the jobs_provider_enum via a new migration if it isn’t already present.
  4. Test end-to-end: create an instance from the frontend and confirm the job transitions PENDING → RUNNING → SUCCESS and the status-callback reaches Strapi.

Q: Provisioning stuck in “provisioning”/RUNNING status

A: Check the worker logs (docker logs cloud-connector-worker, not the API server container). Look for provider API errors, timeouts, or a dead-lettered job (cloud.connector.dead.q).

Q: Instance created but Sencai status is still “error”

A: The status-callback to Strapi may have failed (check X-Service-Secret/CLOUD_CALLBACK_SECRET matches on both sides) or the job hit a dead-letter after exhausting retries. Check Strapi logs and the dead-letter queue before manually editing instance status.

Q: Can’t connect to instance via SSH

A: Verify:

  • Public IP is correct
  • SSH port 22 is open in the provider’s security group/firewall
  • SSH key matches the instance
  • Instance OS reports ready in the provider console

Q: Provider credentials rejected

A: Verify BYOC credentials resolved correctly for the job (per-tenant credential, not just a global .env fallback), then test manually against the provider CLI/SDK, e.g.:

Terminal window
# AWS
aws ec2 describe-instances --region eu-central-1
# Google Cloud
gcloud auth list
gcloud compute instances list
# Azure
az vm list --output table