0
# Dynamic Resources
1
2
Dynamic resources enable creating custom Pulumi resources in TypeScript/JavaScript without building a full provider plugin, perfect for integrating with APIs and services not covered by existing providers.
3
4
## Core Dynamic Resource Classes
5
6
```typescript { .api }
7
class dynamic.Resource extends CustomResource {
8
constructor(
9
provider: ResourceProvider<Inputs, Outputs>,
10
name: string,
11
props: Inputs,
12
opts?: CustomResourceOptions,
13
remote?: boolean
14
);
15
}
16
17
interface ResourceProvider<Inputs, Outputs> {
18
check?(inputs: any): Promise<CheckResult<Inputs>>;
19
create?(inputs: Inputs): Promise<CreateResult<Outputs>>;
20
read?(id: string, props?: any): Promise<ReadResult<Outputs>>;
21
update?(id: string, olds: Outputs, news: Inputs): Promise<UpdateResult<Outputs>>;
22
delete?(id: string, props: Outputs): Promise<void>;
23
diff?(id: string, olds: Outputs, news: Inputs): Promise<DiffResult>;
24
}
25
```
26
27
## Provider Result Types
28
29
```typescript { .api }
30
interface CheckResult<Inputs> {
31
inputs?: Inputs;
32
failures?: CheckFailure[];
33
}
34
35
interface CreateResult<Outputs> {
36
id: string;
37
outs?: Outputs;
38
}
39
40
interface ReadResult<Outputs> {
41
id?: string;
42
outs?: Outputs;
43
}
44
45
interface UpdateResult<Outputs> {
46
outs?: Outputs;
47
}
48
49
interface DiffResult {
50
changes?: boolean;
51
replaces?: string[];
52
stables?: string[];
53
deleteBeforeReplace?: boolean;
54
}
55
56
interface CheckFailure {
57
property: string;
58
reason: string;
59
}
60
```
61
62
## Usage Examples
63
64
### Simple REST API Resource
65
66
```typescript
67
import * as pulumi from "@pulumi/pulumi";
68
import axios from "axios";
69
70
interface ApiResourceInputs {
71
name: string;
72
config: any;
73
apiEndpoint: string;
74
apiToken: string;
75
}
76
77
interface ApiResourceOutputs {
78
id: string;
79
name: string;
80
status: string;
81
createdAt: string;
82
}
83
84
const apiResourceProvider: pulumi.dynamic.ResourceProvider<ApiResourceInputs, ApiResourceOutputs> = {
85
async create(inputs) {
86
const response = await axios.post(`${inputs.apiEndpoint}/resources`, {
87
name: inputs.name,
88
config: inputs.config,
89
}, {
90
headers: {
91
'Authorization': `Bearer ${inputs.apiToken}`,
92
'Content-Type': 'application/json',
93
},
94
});
95
96
return {
97
id: response.data.id,
98
outs: {
99
id: response.data.id,
100
name: response.data.name,
101
status: response.data.status,
102
createdAt: response.data.createdAt,
103
},
104
};
105
},
106
107
async read(id, props) {
108
const inputs = props as ApiResourceInputs;
109
110
try {
111
const response = await axios.get(`${inputs.apiEndpoint}/resources/${id}`, {
112
headers: {
113
'Authorization': `Bearer ${inputs.apiToken}`,
114
},
115
});
116
117
return {
118
id: response.data.id,
119
outs: {
120
id: response.data.id,
121
name: response.data.name,
122
status: response.data.status,
123
createdAt: response.data.createdAt,
124
},
125
};
126
} catch (error) {
127
if (error.response?.status === 404) {
128
return { id: undefined, outs: undefined };
129
}
130
throw error;
131
}
132
},
133
134
async update(id, olds, news) {
135
const response = await axios.put(`${news.apiEndpoint}/resources/${id}`, {
136
name: news.name,
137
config: news.config,
138
}, {
139
headers: {
140
'Authorization': `Bearer ${news.apiToken}`,
141
'Content-Type': 'application/json',
142
},
143
});
144
145
return {
146
outs: {
147
id: response.data.id,
148
name: response.data.name,
149
status: response.data.status,
150
createdAt: response.data.createdAt,
151
},
152
};
153
},
154
155
async delete(id, props) {
156
const inputs = props as ApiResourceOutputs;
157
158
await axios.delete(`${(props as any).apiEndpoint}/resources/${id}`, {
159
headers: {
160
'Authorization': `Bearer ${(props as any).apiToken}`,
161
},
162
});
163
},
164
};
165
166
// Use the dynamic resource
167
const config = new pulumi.Config();
168
169
const apiResource = new pulumi.dynamic.Resource("my-api-resource", apiResourceProvider, "my-resource", {
170
name: "my-custom-resource",
171
config: {
172
setting1: "value1",
173
setting2: config.getNumber("setting2") || 42,
174
},
175
apiEndpoint: config.require("apiEndpoint"),
176
apiToken: config.requireSecret("apiToken"),
177
});
178
179
export const resourceId = apiResource.id;
180
export const resourceStatus = apiResource.outs.apply(o => o?.status);
181
```
182
183
### Database Migration Resource
184
185
```typescript
186
import * as pulumi from "@pulumi/pulumi";
187
import { Client } from "pg";
188
189
interface MigrationInputs {
190
connectionString: string;
191
migrationScript: string;
192
version: string;
193
}
194
195
interface MigrationOutputs {
196
version: string;
197
appliedAt: string;
198
checksum: string;
199
}
200
201
const migrationProvider: pulumi.dynamic.ResourceProvider<MigrationInputs, MigrationOutputs> = {
202
async create(inputs) {
203
const client = new Client({ connectionString: inputs.connectionString });
204
await client.connect();
205
206
try {
207
// Create migrations table if it doesn't exist
208
await client.query(`
209
CREATE TABLE IF NOT EXISTS schema_migrations (
210
version VARCHAR(255) PRIMARY KEY,
211
applied_at TIMESTAMP DEFAULT NOW(),
212
checksum VARCHAR(255)
213
)
214
`);
215
216
// Calculate checksum
217
const crypto = require('crypto');
218
const checksum = crypto.createHash('md5').update(inputs.migrationScript).digest('hex');
219
220
// Check if migration already applied
221
const existing = await client.query(
222
'SELECT * FROM schema_migrations WHERE version = $1',
223
[inputs.version]
224
);
225
226
if (existing.rows.length > 0) {
227
if (existing.rows[0].checksum !== checksum) {
228
throw new Error(`Migration ${inputs.version} checksum mismatch`);
229
}
230
// Already applied, return existing record
231
return {
232
id: inputs.version,
233
outs: {
234
version: existing.rows[0].version,
235
appliedAt: existing.rows[0].applied_at.toISOString(),
236
checksum: existing.rows[0].checksum,
237
},
238
};
239
}
240
241
// Apply migration
242
await client.query(inputs.migrationScript);
243
244
// Record migration
245
const result = await client.query(
246
'INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2) RETURNING *',
247
[inputs.version, checksum]
248
);
249
250
return {
251
id: inputs.version,
252
outs: {
253
version: result.rows[0].version,
254
appliedAt: result.rows[0].applied_at.toISOString(),
255
checksum: result.rows[0].checksum,
256
},
257
};
258
} finally {
259
await client.end();
260
}
261
},
262
263
async read(id, props) {
264
const inputs = props as MigrationInputs;
265
const client = new Client({ connectionString: inputs.connectionString });
266
await client.connect();
267
268
try {
269
const result = await client.query(
270
'SELECT * FROM schema_migrations WHERE version = $1',
271
[id]
272
);
273
274
if (result.rows.length === 0) {
275
return { id: undefined, outs: undefined };
276
}
277
278
return {
279
id: result.rows[0].version,
280
outs: {
281
version: result.rows[0].version,
282
appliedAt: result.rows[0].applied_at.toISOString(),
283
checksum: result.rows[0].checksum,
284
},
285
};
286
} finally {
287
await client.end();
288
}
289
},
290
291
async delete(id, props) {
292
// Migrations typically aren't rolled back automatically
293
console.log(`Migration ${id} marked for deletion but not rolled back`);
294
},
295
};
296
297
// Use the migration resource
298
const dbConnectionString = new pulumi.Config().requireSecret("dbConnectionString");
299
300
const migration = new pulumi.dynamic.Resource("user-table-migration", migrationProvider, "v001_create_users", {
301
connectionString: dbConnectionString,
302
version: "v001_create_users",
303
migrationScript: `
304
CREATE TABLE users (
305
id SERIAL PRIMARY KEY,
306
email VARCHAR(255) UNIQUE NOT NULL,
307
created_at TIMESTAMP DEFAULT NOW()
308
);
309
CREATE INDEX idx_users_email ON users(email);
310
`,
311
});
312
```
313
314
### File System Resource
315
316
```typescript
317
import * as pulumi from "@pulumi/pulumi";
318
import * as fs from "fs";
319
import * as path from "path";
320
321
interface FileResourceInputs {
322
filePath: string;
323
content: string;
324
permissions?: string;
325
}
326
327
interface FileResourceOutputs {
328
filePath: string;
329
size: number;
330
checksum: string;
331
permissions: string;
332
}
333
334
const fileProvider: pulumi.dynamic.ResourceProvider<FileResourceInputs, FileResourceOutputs> = {
335
async check(inputs) {
336
const failures: pulumi.dynamic.CheckFailure[] = [];
337
338
if (!inputs.filePath) {
339
failures.push({ property: "filePath", reason: "filePath is required" });
340
}
341
342
if (!inputs.content) {
343
failures.push({ property: "content", reason: "content is required" });
344
}
345
346
return { inputs, failures };
347
},
348
349
async create(inputs) {
350
const filePath = path.resolve(inputs.filePath);
351
const dir = path.dirname(filePath);
352
353
// Ensure directory exists
354
fs.mkdirSync(dir, { recursive: true });
355
356
// Write file
357
fs.writeFileSync(filePath, inputs.content);
358
359
// Set permissions if specified
360
if (inputs.permissions) {
361
fs.chmodSync(filePath, inputs.permissions);
362
}
363
364
// Get file stats
365
const stats = fs.statSync(filePath);
366
const crypto = require('crypto');
367
const checksum = crypto.createHash('md5').update(inputs.content).digest('hex');
368
369
return {
370
id: filePath,
371
outs: {
372
filePath: filePath,
373
size: stats.size,
374
checksum: checksum,
375
permissions: stats.mode.toString(8),
376
},
377
};
378
},
379
380
async read(id, props) {
381
try {
382
if (!fs.existsSync(id)) {
383
return { id: undefined, outs: undefined };
384
}
385
386
const content = fs.readFileSync(id, 'utf8');
387
const stats = fs.statSync(id);
388
const crypto = require('crypto');
389
const checksum = crypto.createHash('md5').update(content).digest('hex');
390
391
return {
392
id: id,
393
outs: {
394
filePath: id,
395
size: stats.size,
396
checksum: checksum,
397
permissions: stats.mode.toString(8),
398
},
399
};
400
} catch (error) {
401
return { id: undefined, outs: undefined };
402
}
403
},
404
405
async update(id, olds, news) {
406
// Write updated content
407
fs.writeFileSync(id, news.content);
408
409
// Update permissions if changed
410
if (news.permissions && news.permissions !== olds.permissions) {
411
fs.chmodSync(id, news.permissions);
412
}
413
414
// Get updated stats
415
const stats = fs.statSync(id);
416
const crypto = require('crypto');
417
const checksum = crypto.createHash('md5').update(news.content).digest('hex');
418
419
return {
420
outs: {
421
filePath: id,
422
size: stats.size,
423
checksum: checksum,
424
permissions: stats.mode.toString(8),
425
},
426
};
427
},
428
429
async delete(id, props) {
430
if (fs.existsSync(id)) {
431
fs.unlinkSync(id);
432
}
433
},
434
435
async diff(id, olds, news) {
436
const changes = olds.content !== news.content || olds.permissions !== news.permissions;
437
return { changes };
438
},
439
};
440
441
// Use the file resource
442
const configFile = new pulumi.dynamic.Resource("app-config", fileProvider, "config-file", {
443
filePath: "./deployed-config.json",
444
content: JSON.stringify({
445
environment: pulumi.getStack(),
446
timestamp: new Date().toISOString(),
447
}, null, 2),
448
permissions: "0644",
449
});
450
```
451
452
## Best Practices
453
454
- Implement proper error handling in all provider methods
455
- Use appropriate HTTP status codes and error messages
456
- Implement idempotent operations (create should handle existing resources)
457
- Use checksums or version tracking for update detection
458
- Implement proper cleanup in delete operations
459
- Validate inputs in the check method
460
- Handle external API rate limiting and retries
461
- Use secrets for sensitive configuration like API tokens
462
- Consider using diff method for more efficient updates
463
- Test dynamic resources thoroughly in development environments
464
- Document expected inputs and outputs clearly
465
- Handle partial failures gracefully
466
- Use appropriate timeouts for external API calls