-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathindex.js
More file actions
656 lines (555 loc) · 24.9 KB
/
index.js
File metadata and controls
656 lines (555 loc) · 24.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
// GitHub Action for deploying ECS Express services
// Trigger deployment test
const core = require('@actions/core');
const {
ECSClient,
DescribeServicesCommand,
DescribeExpressGatewayServiceCommand,
DescribeServiceDeploymentsCommand,
ListServiceDeploymentsCommand,
CreateExpressGatewayServiceCommand,
UpdateExpressGatewayServiceCommand,
TagResourceCommand,
UntagResourceCommand
} = require('@aws-sdk/client-ecs');
/**
* Parse tags from JSON format input
* Expected format: [{"key":"Environment","value":"Production"}]
* @param {string} tagsInput - JSON string containing tag array
* @returns {Array} Array of tag objects with key and value properties
*/
function parseTagsFromJSON(tagsInput) {
if (!tagsInput || tagsInput.trim() === '') {
return [];
}
try {
const tags = JSON.parse(tagsInput);
if (!Array.isArray(tags)) {
throw new Error('Tags must be an array');
}
return tags;
} catch (error) {
throw new Error(`Invalid tags JSON: ${error.message}`);
}
}
/**
* Parse tags from multiline format input
* Expected format: key=value (one per line)
* @param {string} tagsInput - Multiline string with key=value pairs
* @returns {Array} Array of tag objects with key and value properties
*/
function parseTagsFromMultiline(tagsInput) {
if (!tagsInput || tagsInput.trim() === '') {
return [];
}
const tags = [];
const lines = tagsInput.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === '') {
continue; // Skip empty lines
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
throw new Error(`Invalid tag format: "${trimmedLine}". Expected format: key=value`);
}
const key = trimmedLine.substring(0, equalIndex).trim();
const value = trimmedLine.substring(equalIndex + 1).trim();
if (key === '') {
throw new Error(`Empty tag key in line: "${trimmedLine}"`);
}
tags.push({ key, value });
}
return tags;
}
/**
* Handle complete tag state management for an existing ECS service during updates
* Implements set difference logic:
* - tagsToRemove = previousTags - desiredTags
* - tagsToAdd = desiredTags - previousTags
* @param {ECSClient} ecs - The ECS client
* @param {string} serviceArn - The ARN of the service to manage tags for
* @param {Array} previousTags - Array of current tag objects on the service
* @param {Array} desiredTags - Array of desired tag objects from input
*/
async function handleTagsOnUpdate(ecs, serviceArn, previousTags, desiredTags) {
// Normalize inputs
const currentTags = previousTags || [];
const newTags = desiredTags || [];
// Create maps for efficient comparison (key -> {key, value})
const currentTagMap = new Map(currentTags.map(tag => [tag.key, tag]));
const desiredTagMap = new Map(newTags.map(tag => [tag.key, tag]));
// Calculate set differences
// tagsToRemove = previousTags - desiredTags (tags that exist currently but not in desired)
const tagsToRemove = currentTags.filter(tag => !desiredTagMap.has(tag.key));
// tagsToAdd = desiredTags - previousTags (tags that are desired but don't exist or have different values)
const tagsToAdd = newTags.filter(tag => {
const currentTag = currentTagMap.get(tag.key);
return !currentTag || currentTag.value !== tag.value;
});
core.debug(`Tag management: ${tagsToRemove.length} to remove, ${tagsToAdd.length} to add`);
// Remove obsolete tags first
if (tagsToRemove.length > 0) {
core.debug(`Removing ${tagsToRemove.length} obsolete tags from service: ${serviceArn}`);
// UntagResource expects an array of tag keys (strings), not tag objects
const tagKeysToRemove = tagsToRemove.map(tag => tag.key);
const untagResourceCommand = new UntagResourceCommand({
resourceArn: serviceArn,
tagKeys: tagKeysToRemove
});
try {
await ecs.send(untagResourceCommand);
core.info(`Successfully removed ${tagsToRemove.length} obsolete tags from service`);
} catch (error) {
core.error(`Failed to remove obsolete tags from service: ${error.message}`);
core.debug(`UntagResource error details: ${JSON.stringify(error, null, 2)}`);
throw new Error(`Tag removal failed: ${error.message}`);
}
}
// Add new/updated tags
if (tagsToAdd.length > 0) {
core.debug(`Adding ${tagsToAdd.length} tags to existing service: ${serviceArn}`);
const tagResourceCommand = new TagResourceCommand({
resourceArn: serviceArn,
tags: tagsToAdd
});
try {
await ecs.send(tagResourceCommand);
core.info(`Successfully applied ${tagsToAdd.length} tags to existing service`);
} catch (error) {
core.error(`Failed to apply tags to existing service: ${error.message}`);
core.debug(`TagResource error details: ${JSON.stringify(error, null, 2)}`);
throw new Error(`Tag application failed: ${error.message}`);
}
}
if (tagsToRemove.length === 0 && tagsToAdd.length === 0) {
core.debug('No tag changes needed - current tags match desired tags');
}
}
/**
* Main entry point for the GitHub Action
* Creates or updates an Amazon ECS Express Mode service
*/
async function run() {
try {
core.info('Amazon ECS Deploy Express Service action started');
// Read required inputs
const serviceName = core.getInput('service-name', { required: false });
const image = core.getInput('image', { required: false });
const executionRoleArn = core.getInput('execution-role-arn', { required: false });
const infrastructureRoleArn = core.getInput('infrastructure-role-arn', { required: false });
// Validate required inputs are not empty
if (!serviceName || serviceName.trim() === '') {
throw new Error('Input required and not supplied: service-name');
}
if (!image || image.trim() === '') {
throw new Error('Input required and not supplied: image');
}
if (!executionRoleArn || executionRoleArn.trim() === '') {
throw new Error('Input required and not supplied: execution-role-arn');
}
if (!infrastructureRoleArn || infrastructureRoleArn.trim() === '') {
throw new Error('Input required and not supplied: infrastructure-role-arn');
}
core.info(`Container image: ${image}`);
core.debug(`Execution role ARN: ${executionRoleArn}`);
core.debug(`Infrastructure role ARN: ${infrastructureRoleArn}`);
// Create ECS client with custom user agent
// Uses default credential provider chain from environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN)
// Region is automatically detected from AWS_REGION or AWS_DEFAULT_REGION environment variables
const ecs = new ECSClient({
customUserAgent: 'amazon-ecs-deploy-express-service-for-github-actions'
});
core.debug('ECS client created successfully');
// Read optional inputs for service identification
const clusterName = core.getInput('cluster', { required: false }) || 'default';
// Read optional container configuration inputs
const containerPort = core.getInput('container-port', { required: false });
const environmentVariables = core.getInput('environment-variables', { required: false });
const secrets = core.getInput('secrets', { required: false });
const command = core.getInput('command', { required: false });
const logGroup = core.getInput('log-group', { required: false });
const logStreamPrefix = core.getInput('log-stream-prefix', { required: false });
const repositoryCredentials = core.getInput('repository-credentials', { required: false });
const tags = core.getInput('tags', { required: false });
const enableTagManagement = core.getInput('mutate-tags-on-update', { required: false });
// Read optional resource configuration inputs
const cpu = core.getInput('cpu', { required: false });
const memory = core.getInput('memory', { required: false });
const taskRoleArn = core.getInput('task-role-arn', { required: false });
// Read optional networking configuration inputs
const subnets = core.getInput('subnets', { required: false });
const securityGroups = core.getInput('security-groups', { required: false });
// Read optional service configuration inputs
const healthCheckPath = core.getInput('health-check-path', { required: false });
// Read optional scaling configuration inputs
const minTaskCount = core.getInput('min-task-count', { required: false });
const maxTaskCount = core.getInput('max-task-count', { required: false });
const autoScalingMetric = core.getInput('auto-scaling-metric', { required: false });
const autoScalingTargetValue = core.getInput('auto-scaling-target-value', { required: false });
// Get AWS region from ECS client config
const region = await ecs.config.region();
core.debug(`AWS Region: ${region}`);
// Parse account ID from execution-role-arn
// Format: arn:aws:iam::ACCOUNT-ID:role/name
const arnParts = executionRoleArn.split(':');
if (arnParts.length < 5) {
throw new Error(`Invalid execution-role-arn format: ${executionRoleArn}`);
}
const accountId = arnParts[4];
core.debug(`AWS Account ID: ${accountId}`);
// Construct service ARN
const serviceArn = `arn:aws:ecs:${region}:${accountId}:service/${clusterName}/${serviceName}`;
core.info(`Constructed service ARN: ${serviceArn}`);
// Check if service exists using DescribeServices and capture current tags
let serviceExists = false;
let currentServiceTags = [];
try {
core.info('Checking if service exists...');
const describeCommand = new DescribeServicesCommand({
cluster: clusterName,
services: [serviceName],
include: ['TAGS']
});
const describeResponse = await ecs.send(describeCommand);
if (describeResponse.services && describeResponse.services.length > 0) {
const service = describeResponse.services[0];
if (service.status !== 'INACTIVE') {
serviceExists = true;
currentServiceTags = service.tags || [];
core.info(`Service exists with status: ${service.status}`);
core.debug(`Current service has ${currentServiceTags.length} tags`);
} else {
core.info('Service exists but is INACTIVE, will create new service');
}
} else {
core.info('Service does not exist, will create new service');
}
} catch (error) {
if (error.name === 'ServiceNotFoundException' || error.name === 'ClusterNotFoundException') {
core.info('Service or cluster not found, will create new service');
serviceExists = false;
} else {
throw error;
}
}
// Log the decision
if (serviceExists) {
core.info('Will UPDATE existing service');
} else {
core.info('Will CREATE new service');
}
// Build SDK command input object
const serviceConfig = {
executionRoleArn: executionRoleArn,
infrastructureRoleArn: infrastructureRoleArn,
primaryContainer: {
image: image
}
};
// Add optional container configuration
if (containerPort && containerPort.trim() !== '') {
serviceConfig.primaryContainer.containerPort = parseInt(containerPort, 10);
}
if (environmentVariables && environmentVariables.trim() !== '') {
try {
const envVars = JSON.parse(environmentVariables);
serviceConfig.primaryContainer.environment = envVars;
} catch (error) {
throw new Error(`Invalid environment-variables JSON: ${error.message}`);
}
}
if (secrets && secrets.trim() !== '') {
try {
const secretsArray = JSON.parse(secrets);
serviceConfig.primaryContainer.secrets = secretsArray;
} catch (error) {
throw new Error(`Invalid secrets JSON: ${error.message}`);
}
}
if (command && command.trim() !== '') {
try {
const commandArray = JSON.parse(command);
serviceConfig.primaryContainer.command = commandArray;
} catch (error) {
throw new Error(`Invalid command JSON: ${error.message}`);
}
}
// Add optional logging configuration
if (logGroup && logGroup.trim() !== '' || logStreamPrefix && logStreamPrefix.trim() !== '') {
serviceConfig.primaryContainer.awsLogsConfiguration = {};
if (logGroup && logGroup.trim() !== '') {
serviceConfig.primaryContainer.awsLogsConfiguration.logGroup = logGroup;
}
if (logStreamPrefix && logStreamPrefix.trim() !== '') {
serviceConfig.primaryContainer.awsLogsConfiguration.logStreamPrefix = logStreamPrefix;
}
}
// Add optional repository credentials
if (repositoryCredentials && repositoryCredentials.trim() !== '') {
serviceConfig.primaryContainer.repositoryCredentials = {
credentialsParameter: repositoryCredentials
};
}
// Add optional resource configuration
if (cpu && cpu.trim() !== '') {
serviceConfig.cpu = cpu;
}
if (memory && memory.trim() !== '') {
serviceConfig.memory = memory;
}
if (taskRoleArn && taskRoleArn.trim() !== '') {
serviceConfig.taskRoleArn = taskRoleArn;
}
// Add optional networking configuration
if (subnets && subnets.trim() !== '') {
const subnetArray = subnets.split(',').map(s => s.trim()).filter(s => s !== '');
if (subnetArray.length > 0) {
serviceConfig.networkConfiguration = {
subnets: subnetArray
};
if (securityGroups && securityGroups.trim() !== '') {
const sgArray = securityGroups.split(',').map(s => s.trim()).filter(s => s !== '');
if (sgArray.length > 0) {
serviceConfig.networkConfiguration.securityGroups = sgArray;
}
}
}
}
// Add service configuration
serviceConfig.serviceName = serviceName;
if (clusterName && clusterName !== 'default') {
serviceConfig.cluster = clusterName;
}
if (healthCheckPath && healthCheckPath.trim() !== '') {
serviceConfig.healthCheckPath = healthCheckPath;
}
// Process tags input
if (tags && tags.trim() !== '') {
try {
let parsedTags;
// Try to parse as JSON first
if (tags.trim().startsWith('[')) {
parsedTags = parseTagsFromJSON(tags);
} else {
// Parse as multiline format
parsedTags = parseTagsFromMultiline(tags);
}
if (parsedTags.length > 0) {
serviceConfig.tags = parsedTags;
}
} catch (error) {
const errorMessage = `Tag parsing failed: ${error.message}`;
core.setFailed(errorMessage);
throw new Error(errorMessage);
}
}
// Add optional scaling configuration
const hasScalingConfig = (minTaskCount && minTaskCount.trim() !== '') ||
(maxTaskCount && maxTaskCount.trim() !== '') ||
(autoScalingMetric && autoScalingMetric.trim() !== '') ||
(autoScalingTargetValue && autoScalingTargetValue.trim() !== '');
if (hasScalingConfig) {
serviceConfig.scalingTarget = {};
if (minTaskCount && minTaskCount.trim() !== '') {
serviceConfig.scalingTarget.minTaskCount = parseInt(minTaskCount, 10);
}
if (maxTaskCount && maxTaskCount.trim() !== '') {
serviceConfig.scalingTarget.maxTaskCount = parseInt(maxTaskCount, 10);
}
if (autoScalingMetric && autoScalingMetric.trim() !== '') {
serviceConfig.scalingTarget.autoScalingMetric = autoScalingMetric;
}
if (autoScalingTargetValue && autoScalingTargetValue.trim() !== '') {
serviceConfig.scalingTarget.autoScalingTargetValue = parseFloat(autoScalingTargetValue);
}
}
// Create or update the service
let response;
let deploymentStartTime;
try {
if (serviceExists && serviceArn) {
// Update existing service
core.info('Updating Express Gateway service...');
// Capture timestamp right before making the API call
deploymentStartTime = new Date();
const updateCommand = new UpdateExpressGatewayServiceCommand({
serviceArn: serviceArn,
...serviceConfig
});
// Handle tags for existing service before update
// Note: UpdateExpressGatewayServiceCommand doesn't support tags parameter
if (enableTagManagement && enableTagManagement.toLowerCase() === 'true') {
const desiredTags = serviceConfig.tags || [];
await handleTagsOnUpdate(ecs, serviceArn, currentServiceTags, desiredTags);
}
response = await ecs.send(updateCommand);
core.info('Service updated successfully');
} else {
// Create new service
core.info('Creating Express Gateway service...');
// Capture timestamp right before making the API call
deploymentStartTime = new Date();
const createCommand = new CreateExpressGatewayServiceCommand(serviceConfig);
response = await ecs.send(createCommand);
core.info('Service created successfully');
// Log successful tag application for service creation
// Note: Tags are only applied during service creation, not during updates
if (serviceConfig.tags && serviceConfig.tags.length > 0) {
core.debug(`Tags successfully included in service creation`);
}
}
} catch (error) {
// Handle AWS SDK errors with helpful messages
if (error.name === 'AccessDeniedException') {
throw new Error(`Access denied: ${error.message}. Please check that the IAM roles have the necessary permissions for ECS Express Mode operations.`);
} else if (error.name === 'InvalidParameterException') {
throw new Error(`Invalid parameter: ${error.message}. Please check your input values.`);
} else if (error.name === 'ClusterNotFoundException') {
throw new Error(`Cluster not found: ${clusterName}. Please check the cluster name and region.`);
} else {
throw error;
}
}
core.debug(`Service response: ${JSON.stringify(response, null, 2)}`);
// Get the service ARN from response
const finalServiceArn = response?.service?.serviceArn || serviceArn;
// Set service ARN output
if (finalServiceArn) {
core.setOutput('service-arn', finalServiceArn);
core.info(`Service ARN: ${finalServiceArn}`);
}
// Wait for deployment to complete
await waitForServiceStable(ecs, finalServiceArn, deploymentStartTime);
} catch (error) {
core.setFailed(error.message);
core.debug(error.stack);
}
}
/**
* Wait for Express Gateway service to reach stable state
* 1. Describe service to get current status
* 2. List service deployments to get deployment ARNs (avoids DB consistency issues)
* 3. Wait for service status to become ACTIVE
* 4. Wait for deployment status to become SUCCESSFUL
*
* @param {ECSClient} ecs - The ECS client
* @param {string} serviceArn - The service ARN
* @param {Date} deploymentStartTime - Timestamp when the deployment was initiated
*/
async function waitForServiceStable(ecs, serviceArn, deploymentStartTime) {
core.info('Waiting for service deployment to complete...');
const maxWaitMinutes = 15;
const pollIntervalSeconds = 15;
const maxWaitMs = maxWaitMinutes * 60 * 1000;
const startTime = Date.now();
let serviceActive = false;
let deploymentArn = null;
while (true) {
// Check timeout
if (Date.now() - startTime > maxWaitMs) {
core.warning(`Deployment is taking longer than ${maxWaitMinutes} minutes. The deployment will continue in the background.`);
break;
}
try {
// Step 1: Check service status using DescribeExpressGatewayService
const describeServiceCommand = new DescribeExpressGatewayServiceCommand({
serviceArn: serviceArn
});
const serviceResponse = await ecs.send(describeServiceCommand);
if (serviceResponse.service) {
const service = serviceResponse.service;
const statusCode = service.status?.statusCode;
// Log the actual service ARN from the response for debugging
if (service.serviceArn && service.serviceArn !== serviceArn) {
core.debug(`Service ARN from response: ${service.serviceArn}`);
}
// Check for failure states
if (statusCode === 'INACTIVE' || statusCode === 'DRAINING') {
throw new Error(`Service entered ${statusCode} state`);
}
// Check if service is ACTIVE
if (statusCode === 'ACTIVE') {
if (!serviceActive) {
serviceActive = true;
}
// Step 2: List service deployments to get deployment ARNs
// This follows CloudFormation's pattern to avoid DB consistency issues
// Filter for deployments created after our action initiated the deployment
if (!deploymentArn) {
try {
core.debug(`Calling ListServiceDeployments with service ARN: ${serviceArn}, filtering for deployments after ${deploymentStartTime.toISOString()}`);
const listDeploymentsCommand = new ListServiceDeploymentsCommand({
cluster: service.cluster || 'default',
service: serviceArn,
createdAt: {
after: deploymentStartTime
}
});
const listResponse = await ecs.send(listDeploymentsCommand);
// Log the full response for debugging
core.debug(`ListServiceDeployments response: ${JSON.stringify(listResponse, null, 2)}`);
const deploymentCount = listResponse.serviceDeployments?.length || 0;
core.info(`Found ${deploymentCount} deployment(s) created after ${deploymentStartTime.toISOString()}`);
if (listResponse.serviceDeployments && listResponse.serviceDeployments.length > 0) {
// Get the most recent deployment (first in the list)
const deployment = listResponse.serviceDeployments[0];
deploymentArn = deployment.serviceDeploymentArn;
core.info(`Monitoring deployment: ${deploymentArn}`);
} else {
core.debug('No deployments found yet, will retry...');
}
} catch (listError) {
core.warning(`ListServiceDeployments error: ${listError.message}. Service ARN: ${serviceArn}`);
}
}
// Step 3: Check deployment status using DescribeServiceDeployments
if (deploymentArn) {
const describeDeploymentCommand = new DescribeServiceDeploymentsCommand({
serviceDeploymentArns: [deploymentArn]
});
const deploymentResponse = await ecs.send(describeDeploymentCommand);
if (deploymentResponse.serviceDeployments && deploymentResponse.serviceDeployments.length > 0) {
const deployment = deploymentResponse.serviceDeployments[0];
const deploymentStatus = deployment.status;
core.info(`Deployment ${deploymentArn} status: ${deploymentStatus}. Will re-poll in ${pollIntervalSeconds} seconds...`);
// Check for deployment failure
if (deploymentStatus === 'FAILED' || deploymentStatus === 'STOPPED') {
throw new Error(`Deployment ${deploymentArn} ${deploymentStatus}`);
}
// Deployment is complete when status is SUCCESSFUL
if (deploymentStatus === 'SUCCESSFUL') {
core.info('Deployment completed successfully');
// Extract endpoint from active configurations
if (service.activeConfigurations &&
service.activeConfigurations.length > 0 &&
service.activeConfigurations[0].ingressPaths &&
service.activeConfigurations[0].ingressPaths.length > 0) {
const endpoint = service.activeConfigurations[0].ingressPaths[0].endpoint;
if (endpoint) {
core.setOutput('endpoint', endpoint);
core.info(`Service endpoint: ${endpoint}`);
}
}
return;
}
}
}
}
}
} catch (error) {
// Only warn on transient errors, throw on actual failures
if (error.message.includes('entered') || error.message.includes('FAILED') || error.message.includes('STOPPED')) {
throw error;
}
core.warning(`Error checking status: ${error.message}`);
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollIntervalSeconds * 1000));
}
}
module.exports = run;
// Execute run() if this module is the entry point
if (require.main === module) {
run();
}