@crossdelta/infrastructure 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,15 +3,55 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@crossdelta/infrastructure.svg)](https://www.npmjs.com/package/@crossdelta/infrastructure)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- Infrastructure-as-Code helpers for deploying microservices to DigitalOcean App Platform with Pulumi.
6
+ Infrastructure-as-Code helpers for deploying microservices to DigitalOcean with Pulumi.
7
+
8
+ > **Note**: This is an MVP architecture optimized for simplicity and cost-efficiency.
9
+ > For production with SLAs, consider managed services or HA clustering.
10
+
11
+ ## Architecture Overview
12
+
13
+ ```mermaid
14
+ graph TB
15
+ subgraph DO[DigitalOcean]
16
+ subgraph APP[App Platform]
17
+ FE[frontend :3000]
18
+ API[api :4000]
19
+ WRK[worker :4001]
20
+ end
21
+
22
+ subgraph DROP[Droplet]
23
+ NATS[NATS + JetStream]
24
+ VOL[(Volume /data)]
25
+ NATS --> VOL
26
+ end
27
+
28
+ APP -->|VPC| DROP
29
+ end
30
+
31
+ style APP fill:#e1f5fe
32
+ style DROP fill:#fff3e0
33
+ style VOL fill:#f5f5f5
34
+ ```
35
+
36
+ ### Platform Strategy
37
+
38
+ | Platform | Use Case | Pros | Cons |
39
+ |----------|----------|------|------|
40
+ | **App Platform** | Stateless services (APIs, frontends) | Auto-scaling, zero-ops, built-in networking | No persistent volumes |
41
+ | **Droplet** | Stateful services (NATS, databases) | Persistent volumes, full control | Manual management |
42
+
43
+ **Why this split?** DigitalOcean App Platform doesn't support persistent volumes.
44
+ Services like NATS with JetStream need persistent storage for message durability.
7
45
 
8
46
  ## Features
9
47
 
48
+ - **Dual Platform Support** - Deploy to App Platform or Droplets from the same config
10
49
  - **Service Discovery** - Auto-discover service configs from `infra/services/*.ts`
11
50
  - **Type-safe Config** - Full TypeScript support with `ServiceConfig` type
12
51
  - **Smart Routing** - Automatic ingress rules for public services
13
- - **Internal Services** - Support for internal-only ports (no public exposure)
14
- - **Multi-Registry** - Docker Hub, GHCR, and DOCR image support
52
+ - **VPC Networking** - Secure private communication between platforms
53
+ - **Persistent Volumes** - Automatic volume creation and mounting for Droplets
54
+ - **Config File Mounting** - Share config files between local dev and production
15
55
  - **URL Generation** - Auto-generate service URLs and port env vars
16
56
 
17
57
  ## Installation
@@ -21,17 +61,44 @@ npm install @crossdelta/infrastructure
21
61
  ```
22
62
 
23
63
  ## Quick Start
24
- ### 1. Create a service config
64
+
65
+ ### 1. Create service configs
25
66
 
26
67
  ```typescript
27
- // infra/services/api-gateway.ts
68
+ // infra/services/api.ts - App Platform (stateless)
28
69
  import type { ServiceConfig } from '@crossdelta/infrastructure'
29
70
 
30
71
  const config: ServiceConfig = {
31
- name: 'api-gateway',
32
- instanceSizeSlug: 'apps-s-1vcpu-0.5gb',
72
+ name: 'api',
33
73
  httpPort: 4000,
34
74
  ingressPrefix: '/api',
75
+ instanceSizeSlug: 'apps-s-1vcpu-0.5gb',
76
+ }
77
+
78
+ export default config
79
+ ```
80
+
81
+ ```typescript
82
+ // infra/services/nats.ts - Droplet (stateful with persistence)
83
+ import { dockerHubImage, type ServiceConfig } from '@crossdelta/infrastructure'
84
+ import { readFileSync } from 'node:fs'
85
+
86
+ const config: ServiceConfig = {
87
+ name: 'nats',
88
+ platform: 'droplet',
89
+ internalPorts: [4222, 8222],
90
+ internalUrl: 'nats://nats:4222',
91
+ image: dockerHubImage('nats', '2.10-alpine'),
92
+ runCommand: '-c /etc/nats/nats.conf',
93
+ droplet: {
94
+ size: 's-1vcpu-1gb',
95
+ volumes: [{ name: 'nats-data', mountPath: '/data', sizeGb: 10 }],
96
+ vpcPorts: [4222, 8222],
97
+ configFile: {
98
+ containerPath: '/etc/nats/nats.conf',
99
+ content: readFileSync('path/to/nats.conf', 'utf-8'),
100
+ },
101
+ },
35
102
  }
36
103
 
37
104
  export default config
@@ -46,60 +113,215 @@ import {
46
113
  buildIngressRules,
47
114
  buildServiceUrlEnvs,
48
115
  buildServicePortEnvs,
116
+ buildDropletServices,
117
+ filterByPlatform,
49
118
  discoverServices,
50
119
  } from '@crossdelta/infrastructure'
51
- import { App } from '@pulumi/digitalocean'
120
+ import { App, Vpc } from '@pulumi/digitalocean'
52
121
 
122
+ const region = 'fra1'
53
123
  const serviceConfigs = discoverServices('services')
54
124
 
125
+ // Split services by platform
126
+ const { appPlatformServices, dropletServices } = filterByPlatform(serviceConfigs)
127
+
128
+ // Create VPC for private networking
129
+ const vpc = new Vpc('platform-vpc', {
130
+ name: 'platform-vpc',
131
+ region,
132
+ ipRange: '10.10.10.0/24',
133
+ })
134
+
135
+ // Deploy Droplet services (stateful)
136
+ const dropletResources = buildDropletServices({
137
+ serviceConfigs: dropletServices,
138
+ region,
139
+ vpcUuid: vpc.id,
140
+ projectName: 'my-platform',
141
+ })
142
+
143
+ // Get env vars for Droplet service URLs
144
+ const dropletServiceEnvs = dropletResources.flatMap(r => [
145
+ { key: `${r.serviceName.toUpperCase()}_URL`, value: r.internalUrl },
146
+ { key: `${r.serviceName.toUpperCase()}_HOST`, value: r.privateIp },
147
+ ])
148
+
149
+ // Deploy App Platform services (stateless)
55
150
  const app = new App('my-app', {
56
151
  spec: {
57
152
  name: 'my-platform',
58
- region: 'fra',
153
+ region: region.replace(/\d+$/, ''), // 'fra1' -> 'fra'
59
154
  envs: [
60
- ...buildServiceUrlEnvs(serviceConfigs),
61
- ...buildServicePortEnvs(serviceConfigs),
155
+ ...buildServiceUrlEnvs(appPlatformServices),
156
+ ...buildServicePortEnvs(appPlatformServices),
157
+ ...dropletServiceEnvs,
62
158
  ],
63
- services: buildServices({ serviceConfigs }),
64
- ingress: { rules: buildIngressRules(serviceConfigs) },
159
+ services: buildServices({ serviceConfigs: appPlatformServices }),
160
+ ingress: { rules: buildIngressRules(appPlatformServices) },
65
161
  },
66
162
  })
67
163
  ```
68
164
 
165
+ ## ServiceConfig API
166
+
167
+ The `ServiceConfig` type is the central configuration for all services:
168
+
169
+ ```typescript
170
+ type ServiceConfig = {
171
+ /** Unique name of the service (required) */
172
+ name: string
173
+
174
+ /** Deployment platform: 'app-platform' (default) or 'droplet' */
175
+ platform?: 'app-platform' | 'droplet'
176
+
177
+ /** Public HTTP port with ingress routing (requires ingressPrefix) */
178
+ httpPort?: number
179
+
180
+ /** Internal-only ports, not publicly accessible */
181
+ internalPorts?: number[]
182
+
183
+ /** Ingress path prefix for public routing (e.g., '/api') */
184
+ ingressPrefix?: string
185
+
186
+ /** Override internal URL (e.g., 'nats://nats:4222' for non-HTTP) */
187
+ internalUrl?: string
188
+
189
+ /** Exclude from deployment */
190
+ skip?: boolean
191
+
192
+ /** Custom image configuration */
193
+ image?: {
194
+ registryType: 'DOCKER_HUB' | 'GHCR' | 'DOCR'
195
+ registry: string // e.g., 'library', 'myorg'
196
+ repository: string // e.g., 'nats', 'platform/api'
197
+ tag: string // e.g., '2.10-alpine', 'latest'
198
+ }
199
+
200
+ /** Command to run in the container */
201
+ runCommand?: string
202
+
203
+ /** App Platform: instance size (e.g., 'apps-s-1vcpu-0.5gb') */
204
+ instanceSizeSlug?: string
205
+
206
+ /** Droplet-specific configuration */
207
+ droplet?: {
208
+ /** Droplet size (e.g., 's-1vcpu-1gb') */
209
+ size: string
210
+
211
+ /** Persistent volumes */
212
+ volumes?: Array<{
213
+ name: string // Volume name (unique)
214
+ mountPath: string // Path in container (e.g., '/data')
215
+ sizeGb: number // Size in GB
216
+ }>
217
+
218
+ /** Ports to open in VPC firewall */
219
+ vpcPorts?: number[]
220
+
221
+ /** Additional Docker run arguments */
222
+ dockerArgs?: string
223
+
224
+ /** Config file to mount into container */
225
+ configFile?: {
226
+ containerPath: string // Path in container
227
+ content: string // File content
228
+ }
229
+ }
230
+ }
231
+ ```
232
+
69
233
  ## Service Configuration Examples
70
234
 
71
- ### Public Service
235
+ ### Public Service (App Platform)
72
236
 
73
237
  ```typescript
74
238
  const config: ServiceConfig = {
75
- name: 'storefront',
239
+ name: 'frontend',
76
240
  httpPort: 3000,
77
241
  ingressPrefix: '/',
78
242
  instanceSizeSlug: 'apps-s-1vcpu-1gb',
79
243
  }
80
244
  ```
81
245
 
82
- ### Internal Service
246
+ ### Internal Service (App Platform)
83
247
 
84
248
  ```typescript
85
249
  const config: ServiceConfig = {
86
- name: 'orders',
250
+ name: 'worker',
87
251
  internalPorts: [4001],
88
252
  instanceSizeSlug: 'apps-s-1vcpu-0.5gb',
89
253
  }
90
254
  ```
91
255
 
92
- ### Internal Service with Custom URL (e.g., NATS)
256
+ ### Stateful Service with Persistence (Droplet)
93
257
 
94
258
  ```typescript
259
+ import { dockerHubImage, type ServiceConfig } from '@crossdelta/infrastructure'
260
+
95
261
  const config: ServiceConfig = {
96
262
  name: 'nats',
263
+ platform: 'droplet',
97
264
  internalPorts: [4222, 8222],
98
265
  internalUrl: 'nats://nats:4222',
99
- instanceSizeSlug: 'apps-s-1vcpu-1gb',
266
+ image: dockerHubImage('nats', '2.10-alpine'),
267
+ runCommand: '-c /etc/nats/nats.conf',
268
+ droplet: {
269
+ size: 's-1vcpu-1gb',
270
+ volumes: [{ name: 'nats-data', mountPath: '/data', sizeGb: 10 }],
271
+ vpcPorts: [4222, 8222],
272
+ configFile: {
273
+ containerPath: '/etc/nats/nats.conf',
274
+ content: readFileSync('services/nats/nats.conf', 'utf-8'),
275
+ },
276
+ },
277
+ }
278
+ ```
279
+
280
+ ### Config File Sharing (Local + Production)
281
+
282
+ One of the key features is sharing configuration between local development and production:
283
+
284
+ ```
285
+ services/nats/
286
+ ├── nats.conf # Shared config (used by both)
287
+ └── scripts/
288
+ └── start-dev.sh # Local dev script
289
+ ```
290
+
291
+ **nats.conf** (shared):
292
+ ```conf
293
+ server_name: my-nats-server
294
+ listen: 0.0.0.0:4222
295
+ http: 0.0.0.0:8222
296
+
297
+ jetstream {
298
+ store_dir: /data
299
+ max_mem: 1G
300
+ max_file: 10G
100
301
  }
101
302
  ```
102
303
 
304
+ **start-dev.sh** (local):
305
+ ```bash
306
+ docker run -d --name nats \
307
+ -v "$(pwd)/nats.conf:/etc/nats/nats.conf:ro" \
308
+ -v "$(pwd)/.nats-data:/data" \
309
+ -p 4222:4222 -p 8222:8222 \
310
+ nats:2.10-alpine -c /etc/nats/nats.conf
311
+ ```
312
+
313
+ **infra/services/nats.ts** (production):
314
+ ```typescript
315
+ droplet: {
316
+ configFile: {
317
+ containerPath: '/etc/nats/nats.conf',
318
+ content: readFileSync('services/nats/nats.conf', 'utf-8'),
319
+ },
320
+ }
321
+ ```
322
+
323
+ → Same config file, same behavior, different environments.
324
+
103
325
  ## CLI: generate-env
104
326
 
105
327
  Generate `.env.local` and service-specific `env.ts` files for local development.
@@ -116,8 +338,6 @@ Add a script to your workspace `package.json`:
116
338
  }
117
339
  ```
118
340
 
119
- Or use `pf dev` which runs `generate-env` automatically before starting services.
120
-
121
341
  ### What it does
122
342
 
123
343
  1. Loads secrets from Pulumi config (dev stack)
@@ -132,21 +352,13 @@ Or use `pf dev` which runs `generate-env` automatically before starting services
132
352
  `.env.local`:
133
353
  ```bash
134
354
  # Service URLs (localhost for local development)
135
- API_GATEWAY_URL=http://localhost:4000
136
- STOREFRONT_URL=http://localhost:3000
355
+ API_URL=http://localhost:4000
356
+ FRONTEND_URL=http://localhost:3000
357
+ NATS_URL=nats://localhost:4222
137
358
 
138
359
  # Service Ports
139
- API_GATEWAY_PORT=4000
140
- STOREFRONT_PORT=3000
141
- ```
142
-
143
- `services/orders/src/env.ts`:
144
- ```typescript
145
- import { getServicePort, getServiceUrl } from '@crossdelta/infrastructure/env'
146
-
147
- export const SERVICE_NAME = 'orders' as const
148
- export const getServicePort = (defaultPort = 8080) => _getServicePort(SERVICE_NAME, defaultPort)
149
- export const getServiceUrl = () => _getServiceUrl(SERVICE_NAME)
360
+ API_PORT=4000
361
+ FRONTEND_PORT=3000
150
362
  ```
151
363
 
152
364
  ## Runtime Helpers
@@ -156,27 +368,51 @@ Use `@crossdelta/infrastructure/env` in your services to read environment config
156
368
  ```typescript
157
369
  import { getServicePort, getServiceUrl } from '@crossdelta/infrastructure/env'
158
370
 
159
- // Get port from ORDERS_PORT env var (fallback: 8080)
160
- const port = getServicePort('orders', 8080)
371
+ // Get port from WORKER_PORT env var (fallback: 8080)
372
+ const port = getServicePort('worker', 8080)
161
373
 
162
- // Get URL from ORDERS_URL env var
163
- const url = getServiceUrl('orders')
374
+ // Get URL from WORKER_URL env var
375
+ const url = getServiceUrl('worker')
164
376
  ```
165
377
 
166
378
  These helpers work both locally (via `.env.local`) and in production (via DO App Platform env injection).
167
379
 
168
- ## API
380
+ ## API Reference
169
381
 
170
382
  ### Main Export (`@crossdelta/infrastructure`)
171
383
 
172
384
  | Function | Description |
173
385
  |----------|-------------|
174
- | `discoverServices(dir)` | Auto-discover service configs (relative to cwd or absolute) |
386
+ | `discoverServices(dir)` | Auto-discover service configs from directory |
175
387
  | `buildServices(options)` | Build App Platform service specs |
176
- | `buildIngressRules(configs)` | Generate ingress rules |
388
+ | `buildIngressRules(configs)` | Generate ingress rules for public services |
177
389
  | `buildServiceUrlEnvs(configs)` | Create SERVICE_NAME_URL env vars |
178
390
  | `buildServicePortEnvs(configs)` | Create SERVICE_NAME_PORT env vars |
179
391
  | `buildLocalUrls(configs)` | Generate localhost URLs for local dev |
392
+ | `filterByPlatform(configs)` | Split configs into App Platform and Droplet services |
393
+ | `buildDropletServices(options)` | Create Droplet resources with volumes and firewall |
394
+ | `dockerHubImage(repo, tag, registry?)` | Create Docker Hub image config (defaults to `library`) |
395
+
396
+ ### Droplet Builder Options
397
+
398
+ ```typescript
399
+ buildDropletServices({
400
+ serviceConfigs: ServiceConfig[] // Services with platform: 'droplet'
401
+ region: string // DO region (e.g., 'fra1')
402
+ vpcUuid: pulumi.Output<string> // VPC ID for private networking
403
+ projectName: string // Prefix for resource names
404
+ })
405
+ ```
406
+
407
+ Returns an array of:
408
+ ```typescript
409
+ {
410
+ serviceName: string // Service name
411
+ droplet: digitalocean.Droplet // Created Droplet resource
412
+ privateIp: pulumi.Output<string> // Private IP in VPC
413
+ internalUrl: string // URL for service discovery
414
+ }
415
+ ```
180
416
 
181
417
  ### Env Export (`@crossdelta/infrastructure/env`)
182
418
 
@@ -185,6 +421,23 @@ These helpers work both locally (via `.env.local`) and in production (via DO App
185
421
  | `getServicePort(name, default)` | Read SERVICE_NAME_PORT from env |
186
422
  | `getServiceUrl(name)` | Read SERVICE_NAME_URL from env |
187
423
 
424
+ ## MVP Limitations & Future Improvements
425
+
426
+ This architecture is designed for MVP/Beta deployments:
427
+
428
+ | Current (MVP) | Future (Production) |
429
+ |---------------|---------------------|
430
+ | Single NATS Droplet | NATS Cluster (3 nodes) or Synadia Cloud |
431
+ | No auto-healing | Kubernetes or managed service |
432
+ | Manual scaling | Auto-scaling policies |
433
+ | Basic monitoring | Prometheus/Grafana stack |
434
+
435
+ **Cost estimate (MVP)**:
436
+ - App Platform: ~$12-24/month (depending on instance sizes)
437
+ - NATS Droplet: ~$6/month (s-1vcpu-1gb)
438
+ - Volume: ~$1/month (10GB)
439
+ - **Total**: ~$19-31/month
440
+
188
441
  ## License
189
442
 
190
443
  MIT
@@ -0,0 +1,38 @@
1
+ import * as digitalocean from '@pulumi/digitalocean';
2
+ import * as pulumi from '@pulumi/pulumi';
3
+ import type { ServiceConfig } from '../types';
4
+ export interface DropletServiceResources {
5
+ droplet: digitalocean.Droplet;
6
+ volume?: digitalocean.Volume;
7
+ volumeAttachment?: digitalocean.VolumeAttachment;
8
+ firewall?: digitalocean.Firewall;
9
+ /** Private IP for VPC communication */
10
+ privateIp: pulumi.Output<string>;
11
+ }
12
+ export interface BuildDropletServicesOptions {
13
+ /** Service configs with platform: 'droplet' */
14
+ serviceConfigs: ServiceConfig[];
15
+ /** DigitalOcean region (e.g., 'fra1') */
16
+ region: string;
17
+ /** VPC UUID for private networking */
18
+ vpcUuid: pulumi.Input<string>;
19
+ /** SSH key IDs for Droplet access (optional) */
20
+ sshKeyIds?: pulumi.Input<pulumi.Input<string>[]>;
21
+ /** Registry credentials for private images (optional) */
22
+ registryCredentials?: pulumi.Output<string>;
23
+ }
24
+ /**
25
+ * Builds Droplet resources for services with platform: 'droplet'.
26
+ *
27
+ * Creates:
28
+ * - Droplet with Docker and cloud-init script
29
+ * - Volume(s) for persistent storage (if configured)
30
+ * - Firewall rules for VPC access
31
+ *
32
+ * @returns Map of service name to created resources
33
+ */
34
+ export declare function buildDropletServices(options: BuildDropletServicesOptions): Map<string, DropletServiceResources>;
35
+ /**
36
+ * Filters service configs by platform type.
37
+ */
38
+ export declare function filterByPlatform(configs: ServiceConfig[], platform: 'app-platform' | 'droplet'): ServiceConfig[];
@@ -1,6 +1,7 @@
1
1
  export * from './config';
2
2
  export * from './discover-services';
3
3
  export * from './docker-hub-image';
4
+ export * from './droplet-builder';
4
5
  export * from './image';
5
6
  export * from './service-builder';
6
7
  export * from './service-runtime';