suthep 0.1.0 → 0.2.0-beta.1

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.
Files changed (72) hide show
  1. package/README.md +172 -71
  2. package/dist/commands/deploy.js +251 -37
  3. package/dist/commands/deploy.js.map +1 -1
  4. package/dist/commands/down.js +179 -0
  5. package/dist/commands/down.js.map +1 -0
  6. package/dist/commands/redeploy.js +59 -0
  7. package/dist/commands/redeploy.js.map +1 -0
  8. package/dist/commands/up.js +213 -0
  9. package/dist/commands/up.js.map +1 -0
  10. package/dist/index.js +36 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/utils/certbot.js +40 -3
  13. package/dist/utils/certbot.js.map +1 -1
  14. package/dist/utils/config-loader.js +30 -0
  15. package/dist/utils/config-loader.js.map +1 -1
  16. package/dist/utils/deployment.js +49 -16
  17. package/dist/utils/deployment.js.map +1 -1
  18. package/dist/utils/docker.js +396 -25
  19. package/dist/utils/docker.js.map +1 -1
  20. package/dist/utils/nginx.js +167 -8
  21. package/dist/utils/nginx.js.map +1 -1
  22. package/docs/README.md +25 -49
  23. package/docs/english/01-introduction.md +84 -0
  24. package/docs/english/02-installation.md +200 -0
  25. package/docs/english/03-quick-start.md +256 -0
  26. package/docs/english/04-configuration.md +358 -0
  27. package/docs/english/05-commands.md +363 -0
  28. package/docs/english/06-examples.md +456 -0
  29. package/docs/english/07-troubleshooting.md +417 -0
  30. package/docs/english/08-advanced.md +411 -0
  31. package/docs/english/README.md +48 -0
  32. package/docs/thai/01-introduction.md +84 -0
  33. package/docs/thai/02-installation.md +200 -0
  34. package/docs/thai/03-quick-start.md +256 -0
  35. package/docs/thai/04-configuration.md +358 -0
  36. package/docs/thai/05-commands.md +363 -0
  37. package/docs/thai/06-examples.md +456 -0
  38. package/docs/thai/07-troubleshooting.md +417 -0
  39. package/docs/thai/08-advanced.md +411 -0
  40. package/docs/thai/README.md +48 -0
  41. package/example/README.md +286 -53
  42. package/example/suthep-complete.yml +103 -0
  43. package/example/suthep-docker-only.yml +71 -0
  44. package/example/suthep-no-docker.yml +51 -0
  45. package/example/suthep-path-routing.yml +62 -0
  46. package/example/suthep.example.yml +89 -0
  47. package/package.json +1 -1
  48. package/src/commands/deploy.ts +322 -50
  49. package/src/commands/down.ts +240 -0
  50. package/src/commands/redeploy.ts +78 -0
  51. package/src/commands/up.ts +271 -0
  52. package/src/index.ts +62 -1
  53. package/src/types/config.ts +25 -24
  54. package/src/utils/certbot.ts +68 -6
  55. package/src/utils/config-loader.ts +40 -0
  56. package/src/utils/deployment.ts +61 -36
  57. package/src/utils/docker.ts +634 -30
  58. package/src/utils/nginx.ts +187 -4
  59. package/suthep-0.1.0-beta.1.tgz +0 -0
  60. package/suthep-0.1.1.tgz +0 -0
  61. package/suthep.example.yml +34 -0
  62. package/suthep.yml +39 -0
  63. package/test +0 -0
  64. package/docs/api-reference.md +0 -545
  65. package/docs/architecture.md +0 -367
  66. package/docs/commands.md +0 -273
  67. package/docs/configuration.md +0 -347
  68. package/docs/examples.md +0 -537
  69. package/docs/getting-started.md +0 -197
  70. package/docs/troubleshooting.md +0 -441
  71. package/example/docker-compose.yml +0 -72
  72. package/example/suthep.yml +0 -31
@@ -1,8 +1,325 @@
1
+ import chalk from 'chalk'
1
2
  import { execa } from 'execa'
2
3
  import type { ServiceConfig } from '../types/config'
3
4
 
5
+ /**
6
+ * Interface for zero-downtime deployment container info
7
+ */
8
+ export interface ZeroDowntimeContainerInfo {
9
+ tempContainerName: string
10
+ tempPort: number
11
+ oldContainerExists: boolean
12
+ }
13
+
14
+ /**
15
+ * Start a new container on a temporary port for zero-downtime deployment
16
+ * Returns information about the temporary container
17
+ */
18
+ export async function startDockerContainerZeroDowntime(
19
+ service: ServiceConfig
20
+ ): Promise<ZeroDowntimeContainerInfo | null> {
21
+ if (!service.docker) {
22
+ return null
23
+ }
24
+
25
+ const { image, container, port } = service.docker
26
+
27
+ if (!image) {
28
+ throw new Error(
29
+ `Image is required for zero-downtime deployment. Please specify an "image" field in the docker configuration for service "${service.name}".`
30
+ )
31
+ }
32
+
33
+ try {
34
+ // Check if old container exists
35
+ let oldContainerExists = false
36
+ let oldContainerRunning = false
37
+ try {
38
+ const { stdout } = await execa('docker', ['inspect', '--type', 'container', container], {
39
+ stderr: 'pipe',
40
+ })
41
+ oldContainerExists = true
42
+
43
+ try {
44
+ const containerInfo = JSON.parse(stdout)
45
+ if (containerInfo && containerInfo[0]) {
46
+ oldContainerRunning = containerInfo[0].State?.Running || false
47
+ console.log(
48
+ chalk.dim(
49
+ ` 📋 Existing container "${container}" found (running: ${oldContainerRunning})`
50
+ )
51
+ )
52
+ }
53
+ } catch (parseError) {
54
+ // If we can't parse, that's okay - we know the container exists
55
+ }
56
+ } catch (error: any) {
57
+ // Container doesn't exist - this is a fresh deployment
58
+ oldContainerExists = false
59
+ console.log(chalk.dim(` 📋 No existing container found, performing fresh deployment`))
60
+ }
61
+
62
+ // For zero-downtime, we need a temporary port and container name
63
+ const tempPort = oldContainerExists ? service.port + 10000 : service.port
64
+ const tempContainerName = oldContainerExists ? `${container}-new` : container
65
+
66
+ // Check if temp container already exists (from a failed previous deployment)
67
+ try {
68
+ await execa('docker', ['inspect', '--type', 'container', tempContainerName], {
69
+ stderr: 'pipe',
70
+ })
71
+ // Temp container exists, remove it
72
+ console.log(chalk.yellow(` 🧹 Cleaning up previous temporary container...`))
73
+ await execa('docker', ['rm', '-f', tempContainerName])
74
+ } catch (error) {
75
+ // Temp container doesn't exist, which is fine
76
+ }
77
+
78
+ // Check if temp port is available
79
+ if (oldContainerExists) {
80
+ try {
81
+ const { stdout: portCheck } = await execa('docker', ['ps', '--format', '{{.Ports}}'])
82
+ const portPattern = new RegExp(`:${tempPort}->`, 'g')
83
+ if (portCheck && portPattern.test(portCheck)) {
84
+ throw new Error(
85
+ `Temporary port ${tempPort} is already in use. Please ensure no other containers are using ports in the range ${service.port}-${tempPort}.`
86
+ )
87
+ }
88
+ } catch (error) {
89
+ if (error instanceof Error && error.message.includes('Temporary port')) {
90
+ throw error
91
+ }
92
+ }
93
+ }
94
+
95
+ // Pull the latest image
96
+ try {
97
+ console.log(chalk.dim(` 📥 Pulling latest image: ${image}...`))
98
+ await execa('docker', ['pull', image])
99
+ console.log(chalk.green(` ✅ Image pulled successfully: ${image}`))
100
+ } catch (error: any) {
101
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
102
+ console.log(
103
+ chalk.yellow(` ⚠️ Failed to pull image ${image}, using existing local image if available`)
104
+ )
105
+ console.log(chalk.dim(` Error: ${errorDetails}`))
106
+ }
107
+
108
+ // Create Docker port binding
109
+ const args = [
110
+ 'run',
111
+ '-d',
112
+ '--name',
113
+ tempContainerName,
114
+ '-p',
115
+ `${tempPort}:${port}`, // Port binding: host:container
116
+ '--restart',
117
+ 'unless-stopped',
118
+ ]
119
+
120
+ // Add environment variables if configured
121
+ if (service.environment) {
122
+ for (const [key, value] of Object.entries(service.environment)) {
123
+ args.push('-e', `${key}=${value}`)
124
+ }
125
+ }
126
+
127
+ args.push(image)
128
+
129
+ try {
130
+ await execa('docker', args)
131
+ if (oldContainerExists) {
132
+ console.log(
133
+ chalk.green(
134
+ ` ✅ Created new container "${tempContainerName}" on temporary port ${tempPort}`
135
+ )
136
+ )
137
+ } else {
138
+ console.log(chalk.green(` ✅ Created and started container: ${tempContainerName}`))
139
+ }
140
+ } catch (error: any) {
141
+ const errorMessage = error?.message || String(error) || 'Unknown error'
142
+ const errorStderr = error?.stderr || ''
143
+ const errorStdout = error?.stdout || ''
144
+
145
+ const fullError = [errorMessage, errorStderr, errorStdout]
146
+ .filter(Boolean)
147
+ .join('\n')
148
+ .toLowerCase()
149
+
150
+ if (
151
+ fullError.includes('port is already allocated') ||
152
+ fullError.includes('bind: address already in use')
153
+ ) {
154
+ throw new Error(
155
+ `Port ${tempPort} is already in use. Please ensure the port is available for zero-downtime deployment.`
156
+ )
157
+ }
158
+
159
+ if (
160
+ fullError.includes('container name is already in use') ||
161
+ fullError.includes('is already in use')
162
+ ) {
163
+ throw new Error(
164
+ `Container name "${tempContainerName}" is already in use. Please remove it manually and try again.`
165
+ )
166
+ }
167
+
168
+ const details = errorStderr || errorStdout || errorMessage
169
+ throw new Error(`Failed to create Docker container "${tempContainerName}": ${details}`)
170
+ }
171
+
172
+ return {
173
+ tempContainerName,
174
+ tempPort,
175
+ oldContainerExists,
176
+ }
177
+ } catch (error: any) {
178
+ if (error instanceof Error && error.message) {
179
+ throw new Error(
180
+ `Failed to start Docker container for zero-downtime deployment of service "${service.name}": ${error.message}`
181
+ )
182
+ }
183
+
184
+ const errorDetails =
185
+ error?.message || error?.stderr || error?.stdout || String(error) || 'Unknown error'
186
+ throw new Error(
187
+ `Failed to start Docker container for zero-downtime deployment of service "${service.name}": ${errorDetails}`
188
+ )
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Swap containers for zero-downtime deployment
194
+ * Stops old container and creates new container on original port
195
+ * Note: Temp container cleanup should happen after nginx is updated to original port
196
+ */
197
+ export async function swapContainersForZeroDowntime(
198
+ service: ServiceConfig,
199
+ tempInfo: ZeroDowntimeContainerInfo
200
+ ): Promise<void> {
201
+ if (!service.docker) {
202
+ return
203
+ }
204
+
205
+ const { container, image, port } = service.docker
206
+
207
+ if (!image) {
208
+ throw new Error(`Image is required for container swap. Service: ${service.name}`)
209
+ }
210
+
211
+ try {
212
+ // Step 1: Stop and remove old container (nginx still pointing to temp port, so no downtime)
213
+ if (tempInfo.oldContainerExists) {
214
+ console.log(chalk.cyan(` 🔄 Stopping old container "${container}"...`))
215
+ try {
216
+ await execa('docker', ['stop', container])
217
+ console.log(chalk.green(` ✅ Stopped old container: ${container}`))
218
+ } catch (error: any) {
219
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
220
+ // If container is already stopped, that's fine
221
+ if (!errorDetails.toLowerCase().includes('already stopped')) {
222
+ console.log(chalk.yellow(` ⚠️ Could not stop old container: ${errorDetails}`))
223
+ }
224
+ }
225
+
226
+ try {
227
+ await execa('docker', ['rm', container])
228
+ console.log(chalk.green(` ✅ Removed old container: ${container}`))
229
+ } catch (error: any) {
230
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
231
+ // If container doesn't exist, that's fine
232
+ if (
233
+ !errorDetails.toLowerCase().includes('no such container') &&
234
+ !errorDetails.toLowerCase().includes('container not found')
235
+ ) {
236
+ console.log(chalk.yellow(` ⚠️ Could not remove old container: ${errorDetails}`))
237
+ }
238
+ }
239
+
240
+ // Step 2: Create new container on original port (temp container still running on temp port)
241
+ console.log(chalk.cyan(` 🔄 Creating new container on production port...`))
242
+
243
+ const args = [
244
+ 'run',
245
+ '-d',
246
+ '--name',
247
+ container,
248
+ '-p',
249
+ `${service.port}:${port}`,
250
+ '--restart',
251
+ 'unless-stopped',
252
+ ]
253
+
254
+ if (service.environment) {
255
+ for (const [key, value] of Object.entries(service.environment)) {
256
+ args.push('-e', `${key}=${value}`)
257
+ }
258
+ }
259
+
260
+ args.push(image)
261
+
262
+ try {
263
+ await execa('docker', args)
264
+ console.log(
265
+ chalk.green(
266
+ ` ✅ Created new container "${container}" on production port ${service.port}`
267
+ )
268
+ )
269
+ } catch (error: any) {
270
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
271
+ throw new Error(`Failed to create final container "${container}": ${errorDetails}`)
272
+ }
273
+ }
274
+ } catch (error: any) {
275
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
276
+ throw new Error(`Failed to swap containers for zero-downtime deployment: ${errorDetails}`)
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Clean up temporary container after zero-downtime deployment
282
+ * Should be called after nginx has been updated to point to the new container
283
+ */
284
+ export async function cleanupTempContainer(tempContainerName: string): Promise<void> {
285
+ try {
286
+ console.log(chalk.cyan(` 🧹 Cleaning up temporary container "${tempContainerName}"...`))
287
+
288
+ // Stop temp container
289
+ try {
290
+ await execa('docker', ['stop', tempContainerName])
291
+ } catch (error: any) {
292
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
293
+ // If already stopped, that's fine
294
+ if (!errorDetails.toLowerCase().includes('already stopped')) {
295
+ console.log(chalk.yellow(` ⚠️ Could not stop temp container: ${errorDetails}`))
296
+ }
297
+ }
298
+
299
+ // Remove temp container
300
+ try {
301
+ await execa('docker', ['rm', tempContainerName])
302
+ console.log(chalk.green(` ✅ Removed temporary container: ${tempContainerName}`))
303
+ } catch (error: any) {
304
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
305
+ // If doesn't exist, that's fine
306
+ if (
307
+ !errorDetails.toLowerCase().includes('no such container') &&
308
+ !errorDetails.toLowerCase().includes('container not found')
309
+ ) {
310
+ console.log(chalk.yellow(` ⚠️ Could not remove temp container: ${errorDetails}`))
311
+ }
312
+ }
313
+ } catch (error: any) {
314
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
315
+ console.log(chalk.yellow(` ⚠️ Error during temp container cleanup: ${errorDetails}`))
316
+ // Don't throw - cleanup failures shouldn't fail the deployment
317
+ }
318
+ }
319
+
4
320
  /**
5
321
  * Start or connect to a Docker container for a service
322
+ * For zero-downtime deployments, use startDockerContainerZeroDowntime instead
6
323
  */
7
324
  export async function startDockerContainer(service: ServiceConfig): Promise<void> {
8
325
  if (!service.docker) {
@@ -12,39 +329,150 @@ export async function startDockerContainer(service: ServiceConfig): Promise<void
12
329
  const { image, container, port } = service.docker
13
330
 
14
331
  try {
15
- // Check if container exists
16
- const { stdout: existingContainers } = await execa('docker', [
17
- 'ps',
18
- '-a',
19
- '--filter',
20
- `name=${container}`,
21
- '--format',
22
- '{{.Names}}',
23
- ])
332
+ // Check if container exists using docker inspect (exact name match)
333
+ let containerExists = false
334
+ let containerState = ''
335
+ try {
336
+ const { stdout } = await execa('docker', ['inspect', '--type', 'container', container], {
337
+ stderr: 'pipe',
338
+ })
339
+ containerExists = true
340
+
341
+ // Parse container state from inspect output
342
+ try {
343
+ const containerInfo = JSON.parse(stdout)
344
+ if (containerInfo && containerInfo[0]) {
345
+ containerState = containerInfo[0].State?.Status || 'unknown'
346
+ const isRunning = containerInfo[0].State?.Running || false
347
+ console.log(
348
+ chalk.dim(
349
+ ` 📋 Container "${container}" exists (state: ${containerState}, running: ${isRunning})`
350
+ )
351
+ )
352
+ }
353
+ } catch (parseError) {
354
+ // If we can't parse, that's okay - we know the container exists
355
+ }
356
+ } catch (error: any) {
357
+ // Container doesn't exist - this is expected for new deployments
358
+ containerExists = false
359
+ const errorMessage = error?.stderr || error?.message || ''
360
+ if (
361
+ errorMessage.includes('No such container') ||
362
+ errorMessage.includes('Error: No such object')
363
+ ) {
364
+ console.log(chalk.dim(` 📋 Container "${container}" does not exist, will create new one`))
365
+ }
366
+ }
367
+
368
+ let shouldCreateNewContainer = true
369
+
370
+ if (containerExists) {
371
+ // Container exists - always remove and recreate for fresh deployment
372
+ if (!image) {
373
+ throw new Error(
374
+ `Container "${container}" exists and needs to be recreated for redeployment. ` +
375
+ `No image specified in configuration for service "${service.name}". ` +
376
+ `Please add an "image" field to the docker configuration to allow container recreation.`
377
+ )
378
+ }
379
+
380
+ // Always recreate container on redeploy to ensure fresh deployment
381
+ console.log(
382
+ chalk.yellow(` 🔄 Removing existing container "${container}" for redeployment...`)
383
+ )
384
+
385
+ // Stop and remove old container (force remove will stop if running)
386
+ try {
387
+ await execa('docker', ['rm', '-f', container])
388
+ console.log(chalk.green(` ✅ Removed existing container: ${container}`))
389
+
390
+ // Verify container was actually removed
391
+ try {
392
+ await execa('docker', ['inspect', '--type', 'container', container], {
393
+ stdout: 'ignore',
394
+ stderr: 'ignore',
395
+ })
396
+ // If we get here, container still exists - this shouldn't happen
397
+ throw new Error(
398
+ `Container "${container}" was not properly removed. Please remove it manually and try again.`
399
+ )
400
+ } catch (verifyError: any) {
401
+ // Container doesn't exist anymore - this is what we want
402
+ const verifyMessage = verifyError?.stderr || verifyError?.message || ''
403
+ if (
404
+ verifyMessage.includes('No such container') ||
405
+ verifyMessage.includes('Error: No such object')
406
+ ) {
407
+ console.log(chalk.dim(` ✓ Verified container "${container}" was removed`))
408
+ }
409
+ }
410
+ } catch (error: any) {
411
+ const errorDetails = error?.stderr || error?.message || String(error) || 'Unknown error'
412
+ // If container doesn't exist, that's okay - it might have been removed already
413
+ if (
414
+ errorDetails.toLowerCase().includes('no such container') ||
415
+ errorDetails.toLowerCase().includes('container not found')
416
+ ) {
417
+ console.log(chalk.yellow(` ⚠️ Container "${container}" was already removed`))
418
+ } else {
419
+ throw new Error(
420
+ `Failed to remove old container "${container}" for service "${service.name}": ${errorDetails}`
421
+ )
422
+ }
423
+ }
424
+ // Will create new container below with fresh image
425
+ }
426
+
427
+ // Create new container (either doesn't exist, or was recreated above)
428
+ if (shouldCreateNewContainer && image) {
429
+ // Pull the latest image before creating container
430
+ try {
431
+ console.log(chalk.dim(` 📥 Pulling latest image: ${image}...`))
432
+ await execa('docker', ['pull', image])
433
+ console.log(chalk.green(` ✅ Image pulled successfully: ${image}`))
434
+ } catch (error: any) {
435
+ // If pull fails, log warning but continue (image might be local or pull might fail)
436
+ const errorDetails = error?.stderr || error?.message || 'Unknown error'
437
+ console.log(
438
+ chalk.yellow(
439
+ ` ⚠️ Failed to pull image ${image}, using existing local image if available`
440
+ )
441
+ )
442
+ console.log(chalk.dim(` Error: ${errorDetails}`))
443
+ }
24
444
 
25
- if (existingContainers.includes(container)) {
26
- // Container exists, check if it's running
27
- const { stdout: runningContainers } = await execa('docker', [
28
- 'ps',
29
- '--filter',
30
- `name=${container}`,
31
- '--format',
32
- '{{.Names}}',
33
- ])
34
-
35
- if (!runningContainers.includes(container)) {
36
- // Container exists but not running, start it
37
- await execa('docker', ['start', container])
38
- }
39
- } else if (image) {
40
445
  // Container doesn't exist and image is provided, create and run it
446
+ // First check if the host port is already in use
447
+ try {
448
+ const { stdout: portCheck } = await execa('docker', ['ps', '--format', '{{.Ports}}'])
449
+
450
+ // Check if port is already mapped
451
+ const portPattern = new RegExp(`:${service.port}->`, 'g')
452
+ if (portCheck && portPattern.test(portCheck)) {
453
+ throw new Error(
454
+ `Port ${service.port} is already in use by another container. Please use a different port for service "${service.name}".`
455
+ )
456
+ }
457
+ } catch (error) {
458
+ // If docker ps fails or port check fails, we'll let docker run handle it
459
+ // But if it's our custom error, rethrow it
460
+ if (error instanceof Error && error.message.includes('Port')) {
461
+ throw error
462
+ }
463
+ }
464
+
465
+ // Create Docker port binding: hostPort:containerPort
466
+ // service.port = host port (accessible from host machine)
467
+ // port = container port (what the app listens on inside container)
468
+ // Format: -p hostPort:containerPort
41
469
  const args = [
42
470
  'run',
43
471
  '-d',
44
472
  '--name',
45
473
  container,
46
474
  '-p',
47
- `${service.port}:${port}`,
475
+ `${service.port}:${port}`, // Port binding: host:container
48
476
  '--restart',
49
477
  'unless-stopped',
50
478
  ]
@@ -58,13 +486,87 @@ export async function startDockerContainer(service: ServiceConfig): Promise<void
58
486
 
59
487
  args.push(image)
60
488
 
61
- await execa('docker', args)
62
- } else {
63
- throw new Error(`Container ${container} not found and no image specified`)
489
+ try {
490
+ await execa('docker', args)
491
+ console.log(chalk.green(` Created and started container: ${container}`))
492
+ } catch (error: any) {
493
+ // Extract error details from execa error
494
+ const errorMessage = error?.message || String(error) || 'Unknown error'
495
+ const errorStderr = error?.stderr || ''
496
+ const errorStdout = error?.stdout || ''
497
+
498
+ const fullError = [errorMessage, errorStderr, errorStdout]
499
+ .filter(Boolean)
500
+ .join('\n')
501
+ .toLowerCase()
502
+
503
+ // Check if error is due to port binding
504
+ if (
505
+ fullError.includes('port is already allocated') ||
506
+ fullError.includes('bind: address already in use') ||
507
+ fullError.includes('port already in use') ||
508
+ fullError.includes('port is already in use')
509
+ ) {
510
+ throw new Error(
511
+ `Port ${service.port} is already in use. Please use a different port for service "${service.name}".`
512
+ )
513
+ }
514
+
515
+ // Check if error is due to container name already in use
516
+ if (
517
+ fullError.includes('container name is already in use') ||
518
+ fullError.includes('is already in use')
519
+ ) {
520
+ throw new Error(
521
+ `Container name "${container}" is already in use. This might happen if the container was created between checks. ` +
522
+ `Please remove the container manually or wait a moment and try again.`
523
+ )
524
+ }
525
+
526
+ // Check if error is due to image not found
527
+ if (
528
+ fullError.includes('no such image') ||
529
+ fullError.includes('pull access denied') ||
530
+ fullError.includes('repository does not exist')
531
+ ) {
532
+ throw new Error(
533
+ `Docker image "${image}" not found or cannot be accessed. ` +
534
+ `Please verify the image name and ensure you have access to pull it.`
535
+ )
536
+ }
537
+
538
+ // Generic error with more details
539
+ const details = errorStderr || errorStdout || errorMessage
540
+ throw new Error(`Failed to create Docker container "${container}": ${details}`)
541
+ }
542
+ } else if (shouldCreateNewContainer && !image) {
543
+ // Only throw error if we need to create a container but no image is provided
544
+ throw new Error(
545
+ `Container "${container}" does not exist and no image specified in configuration. ` +
546
+ `Please either:\n` +
547
+ ` 1. Add an "image" field to the docker configuration for service "${service.name}", or\n` +
548
+ ` 2. Create the container "${container}" manually before deploying.`
549
+ )
64
550
  }
65
- } catch (error) {
551
+ // If shouldCreateNewContainer is false, it means we successfully handled an existing container
552
+ } catch (error: any) {
553
+ // If error is already a well-formed Error with a message, preserve it
554
+ if (error instanceof Error && error.message) {
555
+ // Check if the error message already includes context about the container/service
556
+ if (error.message.includes(container) || error.message.includes(service.name)) {
557
+ throw error
558
+ }
559
+ // Otherwise, wrap with more context
560
+ throw new Error(
561
+ `Failed to start Docker container "${container}" for service "${service.name}": ${error.message}`
562
+ )
563
+ }
564
+
565
+ // Handle non-Error objects or errors without messages
566
+ const errorDetails =
567
+ error?.message || error?.stderr || error?.stdout || String(error) || 'Unknown error'
66
568
  throw new Error(
67
- `Failed to start Docker container: ${error instanceof Error ? error.message : error}`
569
+ `Failed to start Docker container "${container}" for service "${service.name}": ${errorDetails}`
68
570
  )
69
571
  }
70
572
  }
@@ -149,3 +651,105 @@ export async function inspectContainer(containerName: string): Promise<any> {
149
651
  )
150
652
  }
151
653
  }
654
+
655
+ /**
656
+ * Get the port mapping for an existing container
657
+ * Returns the port mapping in format "hostPort:containerPort" or null if not found
658
+ */
659
+ export async function getContainerPortMapping(containerName: string): Promise<string | null> {
660
+ try {
661
+ const containerInfo = await inspectContainer(containerName)
662
+ const portBindings = containerInfo.NetworkSettings?.Ports
663
+
664
+ if (!portBindings) {
665
+ return null
666
+ }
667
+
668
+ // Find the first port binding
669
+ for (const [containerPort, hostBindings] of Object.entries(portBindings)) {
670
+ if (hostBindings && Array.isArray(hostBindings) && hostBindings.length > 0) {
671
+ const hostPort = hostBindings[0].HostPort
672
+ // Remove /tcp or /udp suffix from container port
673
+ const cleanContainerPort = containerPort.replace(/\/.*$/, '')
674
+ return `${hostPort}:${cleanContainerPort}`
675
+ }
676
+ }
677
+
678
+ return null
679
+ } catch (error) {
680
+ return null
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Check if container needs to be recreated based on configuration changes
686
+ */
687
+ export async function needsRecreate(
688
+ service: ServiceConfig,
689
+ containerName: string
690
+ ): Promise<boolean> {
691
+ if (!service.docker) {
692
+ return false
693
+ }
694
+
695
+ const expectedPortMapping = `${service.port}:${service.docker.port}`
696
+ const currentPortMapping = await getContainerPortMapping(containerName)
697
+
698
+ // If port mapping is different, need to recreate
699
+ if (currentPortMapping !== expectedPortMapping) {
700
+ return true
701
+ }
702
+
703
+ // Check if image is different (if image is specified in config)
704
+ if (service.docker.image) {
705
+ try {
706
+ const containerInfo = await inspectContainer(containerName)
707
+ const currentImage = containerInfo.Config?.Image
708
+
709
+ if (currentImage && currentImage !== service.docker.image) {
710
+ return true
711
+ }
712
+ } catch (error) {
713
+ // If we can't check, assume no recreation needed
714
+ }
715
+ }
716
+
717
+ // Check if environment variables have changed
718
+ if (service.environment) {
719
+ try {
720
+ const containerInfo = await inspectContainer(containerName)
721
+ const currentEnv = containerInfo.Config?.Env || []
722
+
723
+ // Convert current env array to object
724
+ const currentEnvObj: Record<string, string> = {}
725
+ for (const envVar of currentEnv) {
726
+ const [key, ...valueParts] = envVar.split('=')
727
+ if (key) {
728
+ currentEnvObj[key] = valueParts.join('=')
729
+ }
730
+ }
731
+
732
+ // Compare with expected environment variables
733
+ for (const [key, value] of Object.entries(service.environment)) {
734
+ if (currentEnvObj[key] !== value) {
735
+ return true // Environment variable changed, need to recreate
736
+ }
737
+ }
738
+
739
+ // Check if any environment variables were removed
740
+ for (const key of Object.keys(currentEnvObj)) {
741
+ // Skip PATH and other system variables
742
+ if (key === 'PATH' || key === 'HOSTNAME' || key.startsWith('_')) {
743
+ continue
744
+ }
745
+ // If a variable exists in container but not in config, and it was explicitly set before
746
+ // we'll recreate to ensure consistency (this is a conservative approach)
747
+ // For now, we only check if config vars match, not if extra vars exist
748
+ }
749
+ } catch (error) {
750
+ // If we can't check, assume no recreation needed
751
+ }
752
+ }
753
+
754
+ return false
755
+ }