suthep 1.0.0
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/.editorconfig +17 -0
- package/.github/workflows/publish.yml +42 -0
- package/.prettierignore +6 -0
- package/.prettierrc +7 -0
- package/.scannerwork/.sonar_lock +0 -0
- package/.scannerwork/report-task.txt +6 -0
- package/.vscode/settings.json +19 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/dist/commands/deploy.js +371 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/down.js +179 -0
- package/dist/commands/down.js.map +1 -0
- package/dist/commands/init.js +188 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/setup.js +90 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/up.js +213 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/certbot.js +64 -0
- package/dist/utils/certbot.js.map +1 -0
- package/dist/utils/config-loader.js +127 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/deployment.js +85 -0
- package/dist/utils/deployment.js.map +1 -0
- package/dist/utils/docker.js +425 -0
- package/dist/utils/docker.js.map +1 -0
- package/dist/utils/env-loader.js +53 -0
- package/dist/utils/env-loader.js.map +1 -0
- package/dist/utils/nginx.js +378 -0
- package/dist/utils/nginx.js.map +1 -0
- package/docs/README.md +38 -0
- package/docs/english/01-introduction.md +84 -0
- package/docs/english/02-installation.md +200 -0
- package/docs/english/03-quick-start.md +258 -0
- package/docs/english/04-configuration.md +433 -0
- package/docs/english/05-commands.md +336 -0
- package/docs/english/06-examples.md +456 -0
- package/docs/english/07-troubleshooting.md +417 -0
- package/docs/english/08-advanced.md +411 -0
- package/docs/english/README.md +48 -0
- package/docs/thai/01-introduction.md +84 -0
- package/docs/thai/02-installation.md +200 -0
- package/docs/thai/03-quick-start.md +258 -0
- package/docs/thai/04-configuration.md +433 -0
- package/docs/thai/05-commands.md +336 -0
- package/docs/thai/06-examples.md +456 -0
- package/docs/thai/07-troubleshooting.md +417 -0
- package/docs/thai/08-advanced.md +411 -0
- package/docs/thai/README.md +48 -0
- package/example/suthep-complete.yml +103 -0
- package/example/suthep-docker-only.yml +71 -0
- package/example/suthep-env-example.yml +113 -0
- package/example/suthep-no-docker.yml +51 -0
- package/example/suthep-path-routing.yml +62 -0
- package/example/suthep.example.yml +88 -0
- package/package.json +51 -0
- package/src/commands/deploy.ts +488 -0
- package/src/commands/down.ts +240 -0
- package/src/commands/init.ts +214 -0
- package/src/commands/setup.ts +112 -0
- package/src/commands/up.ts +271 -0
- package/src/index.ts +109 -0
- package/src/types/config.ts +52 -0
- package/src/utils/__tests__/certbot.test.ts +222 -0
- package/src/utils/__tests__/config-loader.test.ts +419 -0
- package/src/utils/__tests__/deployment.test.ts +243 -0
- package/src/utils/__tests__/nginx.test.ts +412 -0
- package/src/utils/certbot.ts +144 -0
- package/src/utils/config-loader.ts +184 -0
- package/src/utils/deployment.ts +157 -0
- package/src/utils/docker.ts +768 -0
- package/src/utils/env-loader.ts +135 -0
- package/src/utils/nginx.ts +443 -0
- package/suthep-1.0.0.tgz +0 -0
- package/suthep.example.yml +98 -0
- package/suthep.yml +39 -0
- package/todo.md +6 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +46 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import yaml from 'js-yaml'
|
|
4
|
+
import { loadConfig, saveConfig } from '../config-loader'
|
|
5
|
+
import type { DeployConfig } from '../../types/config'
|
|
6
|
+
|
|
7
|
+
// Mock fs-extra
|
|
8
|
+
vi.mock('fs-extra', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
readFile: vi.fn(),
|
|
11
|
+
writeFile: vi.fn(),
|
|
12
|
+
pathExists: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Mock js-yaml
|
|
17
|
+
vi.mock('js-yaml', () => ({
|
|
18
|
+
default: {
|
|
19
|
+
load: vi.fn(),
|
|
20
|
+
dump: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
describe('config-loader', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('loadConfig', () => {
|
|
30
|
+
it('should load and parse a valid config file', async () => {
|
|
31
|
+
const mockConfig: DeployConfig = {
|
|
32
|
+
project: {
|
|
33
|
+
name: 'test-project',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
},
|
|
36
|
+
services: [
|
|
37
|
+
{
|
|
38
|
+
name: 'api',
|
|
39
|
+
port: 3000,
|
|
40
|
+
domains: ['api.example.com'],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
nginx: {
|
|
44
|
+
configPath: '/etc/nginx/sites-available',
|
|
45
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
46
|
+
},
|
|
47
|
+
certbot: {
|
|
48
|
+
email: 'admin@example.com',
|
|
49
|
+
staging: false,
|
|
50
|
+
},
|
|
51
|
+
deployment: {
|
|
52
|
+
strategy: 'rolling',
|
|
53
|
+
healthCheckTimeout: 30000,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
58
|
+
vi.mocked(yaml.load).mockReturnValue(mockConfig)
|
|
59
|
+
|
|
60
|
+
const result = await loadConfig('suthep.yml')
|
|
61
|
+
|
|
62
|
+
expect(fs.readFile).toHaveBeenCalledWith('suthep.yml', 'utf8')
|
|
63
|
+
expect(yaml.load).toHaveBeenCalledWith('mock yaml content')
|
|
64
|
+
expect(result).toEqual(mockConfig)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should throw error if config file cannot be read', async () => {
|
|
68
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'))
|
|
69
|
+
|
|
70
|
+
await expect(loadConfig('nonexistent.yml')).rejects.toThrow(
|
|
71
|
+
'Failed to load configuration from nonexistent.yml: File not found'
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should throw error if project name is missing', async () => {
|
|
76
|
+
const invalidConfig = {
|
|
77
|
+
services: [
|
|
78
|
+
{
|
|
79
|
+
name: 'api',
|
|
80
|
+
port: 3000,
|
|
81
|
+
domains: ['api.example.com'],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
87
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
88
|
+
|
|
89
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
90
|
+
'Configuration must include project.name'
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should throw error if services array is missing', async () => {
|
|
95
|
+
const invalidConfig = {
|
|
96
|
+
project: {
|
|
97
|
+
name: 'test-project',
|
|
98
|
+
version: '1.0.0',
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
103
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
104
|
+
|
|
105
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
106
|
+
'Configuration must include at least one service'
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should throw error if service name is missing', async () => {
|
|
111
|
+
const invalidConfig = {
|
|
112
|
+
project: {
|
|
113
|
+
name: 'test-project',
|
|
114
|
+
version: '1.0.0',
|
|
115
|
+
},
|
|
116
|
+
services: [
|
|
117
|
+
{
|
|
118
|
+
port: 3000,
|
|
119
|
+
domains: ['api.example.com'],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
125
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
126
|
+
|
|
127
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow('Each service must have a name')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should throw error if service port is missing', async () => {
|
|
131
|
+
const invalidConfig = {
|
|
132
|
+
project: {
|
|
133
|
+
name: 'test-project',
|
|
134
|
+
version: '1.0.0',
|
|
135
|
+
},
|
|
136
|
+
services: [
|
|
137
|
+
{
|
|
138
|
+
name: 'api',
|
|
139
|
+
domains: ['api.example.com'],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
145
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
146
|
+
|
|
147
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow('Service api must have a port')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should throw error if service domains are missing', async () => {
|
|
151
|
+
const invalidConfig = {
|
|
152
|
+
project: {
|
|
153
|
+
name: 'test-project',
|
|
154
|
+
version: '1.0.0',
|
|
155
|
+
},
|
|
156
|
+
services: [
|
|
157
|
+
{
|
|
158
|
+
name: 'api',
|
|
159
|
+
port: 3000,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
165
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
166
|
+
|
|
167
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
168
|
+
'Service api must have at least one domain'
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should throw error if port conflict exists', async () => {
|
|
173
|
+
const invalidConfig = {
|
|
174
|
+
project: {
|
|
175
|
+
name: 'test-project',
|
|
176
|
+
version: '1.0.0',
|
|
177
|
+
},
|
|
178
|
+
services: [
|
|
179
|
+
{
|
|
180
|
+
name: 'api',
|
|
181
|
+
port: 3000,
|
|
182
|
+
domains: ['api.example.com'],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'web',
|
|
186
|
+
port: 3000,
|
|
187
|
+
domains: ['web.example.com'],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
193
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
194
|
+
|
|
195
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
196
|
+
'Port conflict: Service "web" uses port 3000'
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should throw error if duplicate service names exist', async () => {
|
|
201
|
+
const invalidConfig = {
|
|
202
|
+
project: {
|
|
203
|
+
name: 'test-project',
|
|
204
|
+
version: '1.0.0',
|
|
205
|
+
},
|
|
206
|
+
services: [
|
|
207
|
+
{
|
|
208
|
+
name: 'api',
|
|
209
|
+
port: 3000,
|
|
210
|
+
domains: ['api.example.com'],
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'api',
|
|
214
|
+
port: 3001,
|
|
215
|
+
domains: ['api2.example.com'],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
221
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
222
|
+
|
|
223
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
224
|
+
'Duplicate service name: "api" is used multiple times'
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should throw error if Docker container name conflict exists', async () => {
|
|
229
|
+
const invalidConfig = {
|
|
230
|
+
project: {
|
|
231
|
+
name: 'test-project',
|
|
232
|
+
version: '1.0.0',
|
|
233
|
+
},
|
|
234
|
+
services: [
|
|
235
|
+
{
|
|
236
|
+
name: 'api',
|
|
237
|
+
port: 3000,
|
|
238
|
+
domains: ['api.example.com'],
|
|
239
|
+
docker: {
|
|
240
|
+
container: 'my-container',
|
|
241
|
+
port: 80,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'web',
|
|
246
|
+
port: 3001,
|
|
247
|
+
domains: ['web.example.com'],
|
|
248
|
+
docker: {
|
|
249
|
+
container: 'my-container',
|
|
250
|
+
port: 80,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
257
|
+
vi.mocked(yaml.load).mockReturnValue(invalidConfig)
|
|
258
|
+
|
|
259
|
+
await expect(loadConfig('suthep.yml')).rejects.toThrow(
|
|
260
|
+
'Docker container name conflict'
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('should set default nginx config if missing', async () => {
|
|
265
|
+
const configWithoutNginx = {
|
|
266
|
+
project: {
|
|
267
|
+
name: 'test-project',
|
|
268
|
+
version: '1.0.0',
|
|
269
|
+
},
|
|
270
|
+
services: [
|
|
271
|
+
{
|
|
272
|
+
name: 'api',
|
|
273
|
+
port: 3000,
|
|
274
|
+
domains: ['api.example.com'],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
280
|
+
vi.mocked(yaml.load).mockReturnValue(configWithoutNginx)
|
|
281
|
+
|
|
282
|
+
const result = await loadConfig('suthep.yml')
|
|
283
|
+
|
|
284
|
+
expect(result.nginx).toEqual({
|
|
285
|
+
configPath: '/etc/nginx/sites-available',
|
|
286
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('should set default certbot config if missing', async () => {
|
|
291
|
+
const configWithoutCertbot = {
|
|
292
|
+
project: {
|
|
293
|
+
name: 'test-project',
|
|
294
|
+
version: '1.0.0',
|
|
295
|
+
},
|
|
296
|
+
services: [
|
|
297
|
+
{
|
|
298
|
+
name: 'api',
|
|
299
|
+
port: 3000,
|
|
300
|
+
domains: ['api.example.com'],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
306
|
+
vi.mocked(yaml.load).mockReturnValue(configWithoutCertbot)
|
|
307
|
+
|
|
308
|
+
const result = await loadConfig('suthep.yml')
|
|
309
|
+
|
|
310
|
+
expect(result.certbot).toEqual({
|
|
311
|
+
email: '',
|
|
312
|
+
staging: false,
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('should set default deployment config if missing', async () => {
|
|
317
|
+
const configWithoutDeployment = {
|
|
318
|
+
project: {
|
|
319
|
+
name: 'test-project',
|
|
320
|
+
version: '1.0.0',
|
|
321
|
+
},
|
|
322
|
+
services: [
|
|
323
|
+
{
|
|
324
|
+
name: 'api',
|
|
325
|
+
port: 3000,
|
|
326
|
+
domains: ['api.example.com'],
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
vi.mocked(fs.readFile).mockResolvedValue('mock yaml content' as any)
|
|
332
|
+
vi.mocked(yaml.load).mockReturnValue(configWithoutDeployment)
|
|
333
|
+
|
|
334
|
+
const result = await loadConfig('suthep.yml')
|
|
335
|
+
|
|
336
|
+
expect(result.deployment).toEqual({
|
|
337
|
+
strategy: 'rolling',
|
|
338
|
+
healthCheckTimeout: 30000,
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('saveConfig', () => {
|
|
344
|
+
it('should save config to YAML file', async () => {
|
|
345
|
+
const config: DeployConfig = {
|
|
346
|
+
project: {
|
|
347
|
+
name: 'test-project',
|
|
348
|
+
version: '1.0.0',
|
|
349
|
+
},
|
|
350
|
+
services: [
|
|
351
|
+
{
|
|
352
|
+
name: 'api',
|
|
353
|
+
port: 3000,
|
|
354
|
+
domains: ['api.example.com'],
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
nginx: {
|
|
358
|
+
configPath: '/etc/nginx/sites-available',
|
|
359
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
360
|
+
},
|
|
361
|
+
certbot: {
|
|
362
|
+
email: 'admin@example.com',
|
|
363
|
+
staging: false,
|
|
364
|
+
},
|
|
365
|
+
deployment: {
|
|
366
|
+
strategy: 'rolling',
|
|
367
|
+
healthCheckTimeout: 30000,
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const mockYamlContent = 'project:\n name: test-project\n'
|
|
372
|
+
vi.mocked(yaml.dump).mockReturnValue(mockYamlContent)
|
|
373
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined as any)
|
|
374
|
+
|
|
375
|
+
await saveConfig('suthep.yml', config)
|
|
376
|
+
|
|
377
|
+
expect(yaml.dump).toHaveBeenCalledWith(config, {
|
|
378
|
+
indent: 2,
|
|
379
|
+
lineWidth: 120,
|
|
380
|
+
noRefs: true,
|
|
381
|
+
})
|
|
382
|
+
expect(fs.writeFile).toHaveBeenCalledWith('suthep.yml', mockYamlContent, 'utf8')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('should throw error if file write fails', async () => {
|
|
386
|
+
const config: DeployConfig = {
|
|
387
|
+
project: {
|
|
388
|
+
name: 'test-project',
|
|
389
|
+
version: '1.0.0',
|
|
390
|
+
},
|
|
391
|
+
services: [
|
|
392
|
+
{
|
|
393
|
+
name: 'api',
|
|
394
|
+
port: 3000,
|
|
395
|
+
domains: ['api.example.com'],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
nginx: {
|
|
399
|
+
configPath: '/etc/nginx/sites-available',
|
|
400
|
+
reloadCommand: 'sudo nginx -t && sudo systemctl reload nginx',
|
|
401
|
+
},
|
|
402
|
+
certbot: {
|
|
403
|
+
email: 'admin@example.com',
|
|
404
|
+
staging: false,
|
|
405
|
+
},
|
|
406
|
+
deployment: {
|
|
407
|
+
strategy: 'rolling',
|
|
408
|
+
healthCheckTimeout: 30000,
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
vi.mocked(yaml.dump).mockReturnValue('mock yaml')
|
|
413
|
+
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Permission denied'))
|
|
414
|
+
|
|
415
|
+
await expect(saveConfig('suthep.yml', config)).rejects.toThrow('Permission denied')
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
performHealthCheck,
|
|
4
|
+
deployService,
|
|
5
|
+
waitForService,
|
|
6
|
+
} from '../deployment'
|
|
7
|
+
import type { ServiceConfig, DeploymentConfig } from '../../types/config'
|
|
8
|
+
import type { ZeroDowntimeContainerInfo } from '../docker'
|
|
9
|
+
|
|
10
|
+
// Mock global fetch
|
|
11
|
+
const mockFetch = vi.fn()
|
|
12
|
+
global.fetch = mockFetch
|
|
13
|
+
|
|
14
|
+
describe('deployment', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('performHealthCheck', () => {
|
|
20
|
+
it('should return true if health check succeeds', async () => {
|
|
21
|
+
mockFetch.mockResolvedValue({
|
|
22
|
+
ok: true,
|
|
23
|
+
status: 200,
|
|
24
|
+
} as Response)
|
|
25
|
+
|
|
26
|
+
const result = await performHealthCheck('http://localhost:3000/health', 1000)
|
|
27
|
+
|
|
28
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/health', {
|
|
29
|
+
method: 'GET',
|
|
30
|
+
signal: expect.any(AbortSignal),
|
|
31
|
+
})
|
|
32
|
+
expect(result).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should return false if health check fails with non-ok status', async () => {
|
|
36
|
+
mockFetch.mockResolvedValue({
|
|
37
|
+
ok: false,
|
|
38
|
+
status: 500,
|
|
39
|
+
} as Response)
|
|
40
|
+
|
|
41
|
+
const result = await performHealthCheck('http://localhost:3000/health', 1000)
|
|
42
|
+
|
|
43
|
+
expect(result).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should retry on network errors until timeout', async () => {
|
|
47
|
+
mockFetch.mockRejectedValue(new Error('Network error'))
|
|
48
|
+
|
|
49
|
+
const result = await performHealthCheck('http://localhost:3000/health', 3000)
|
|
50
|
+
|
|
51
|
+
expect(result).toBe(false)
|
|
52
|
+
expect(mockFetch).toHaveBeenCalled() // Should have made multiple attempts
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('deployService', () => {
|
|
57
|
+
it('should use rolling deployment strategy', async () => {
|
|
58
|
+
const service: ServiceConfig = {
|
|
59
|
+
name: 'api',
|
|
60
|
+
port: 3000,
|
|
61
|
+
domains: ['api.example.com'],
|
|
62
|
+
healthCheck: {
|
|
63
|
+
path: '/health',
|
|
64
|
+
interval: 30,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const deploymentConfig: DeploymentConfig = {
|
|
69
|
+
strategy: 'rolling',
|
|
70
|
+
healthCheckTimeout: 30000,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tempInfo: ZeroDowntimeContainerInfo = {
|
|
74
|
+
tempContainerName: 'api-new',
|
|
75
|
+
tempPort: 13000,
|
|
76
|
+
oldContainerExists: true,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mockFetch.mockResolvedValue({
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 200,
|
|
82
|
+
} as Response)
|
|
83
|
+
|
|
84
|
+
await deployService(service, deploymentConfig, tempInfo)
|
|
85
|
+
|
|
86
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:13000/health', expect.any(Object))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should use blue-green deployment strategy', async () => {
|
|
90
|
+
const service: ServiceConfig = {
|
|
91
|
+
name: 'api',
|
|
92
|
+
port: 3000,
|
|
93
|
+
domains: ['api.example.com'],
|
|
94
|
+
healthCheck: {
|
|
95
|
+
path: '/health',
|
|
96
|
+
interval: 30,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const deploymentConfig: DeploymentConfig = {
|
|
101
|
+
strategy: 'blue-green',
|
|
102
|
+
healthCheckTimeout: 30000,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tempInfo: ZeroDowntimeContainerInfo = {
|
|
106
|
+
tempContainerName: 'api-new',
|
|
107
|
+
tempPort: 13000,
|
|
108
|
+
oldContainerExists: true,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
mockFetch.mockResolvedValue({
|
|
112
|
+
ok: true,
|
|
113
|
+
status: 200,
|
|
114
|
+
} as Response)
|
|
115
|
+
|
|
116
|
+
await deployService(service, deploymentConfig, tempInfo)
|
|
117
|
+
|
|
118
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:13000/health', expect.any(Object))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should throw error for unknown deployment strategy', async () => {
|
|
122
|
+
const service: ServiceConfig = {
|
|
123
|
+
name: 'api',
|
|
124
|
+
port: 3000,
|
|
125
|
+
domains: ['api.example.com'],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const deploymentConfig = {
|
|
129
|
+
strategy: 'unknown' as any,
|
|
130
|
+
healthCheckTimeout: 30000,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await expect(deployService(service, deploymentConfig)).rejects.toThrow(
|
|
134
|
+
'Unknown deployment strategy: unknown'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should skip health check if no health check configured', async () => {
|
|
139
|
+
const service: ServiceConfig = {
|
|
140
|
+
name: 'api',
|
|
141
|
+
port: 3000,
|
|
142
|
+
domains: ['api.example.com'],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const deploymentConfig: DeploymentConfig = {
|
|
146
|
+
strategy: 'rolling',
|
|
147
|
+
healthCheckTimeout: 30000,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await deployService(service, deploymentConfig, null)
|
|
151
|
+
|
|
152
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should throw error if health check fails during rolling deployment', async () => {
|
|
156
|
+
const service: ServiceConfig = {
|
|
157
|
+
name: 'api',
|
|
158
|
+
port: 3000,
|
|
159
|
+
domains: ['api.example.com'],
|
|
160
|
+
healthCheck: {
|
|
161
|
+
path: '/health',
|
|
162
|
+
interval: 30,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const deploymentConfig: DeploymentConfig = {
|
|
167
|
+
strategy: 'rolling',
|
|
168
|
+
healthCheckTimeout: 1000, // Shorter timeout for test
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tempInfo: ZeroDowntimeContainerInfo = {
|
|
172
|
+
tempContainerName: 'api-new',
|
|
173
|
+
tempPort: 13000,
|
|
174
|
+
oldContainerExists: true,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
mockFetch.mockResolvedValue({
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 500,
|
|
180
|
+
} as Response)
|
|
181
|
+
|
|
182
|
+
await expect(deployService(service, deploymentConfig, tempInfo)).rejects.toThrow(
|
|
183
|
+
'Service api failed health check on temporary container during rolling deployment'
|
|
184
|
+
)
|
|
185
|
+
}, 10000) // Increase test timeout
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('waitForService', () => {
|
|
189
|
+
it('should wait for service with health check', async () => {
|
|
190
|
+
const service: ServiceConfig = {
|
|
191
|
+
name: 'api',
|
|
192
|
+
port: 3000,
|
|
193
|
+
domains: ['api.example.com'],
|
|
194
|
+
healthCheck: {
|
|
195
|
+
path: '/health',
|
|
196
|
+
interval: 30,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
mockFetch.mockResolvedValue({
|
|
201
|
+
ok: true,
|
|
202
|
+
status: 200,
|
|
203
|
+
} as Response)
|
|
204
|
+
|
|
205
|
+
const result = await waitForService(service, 5000)
|
|
206
|
+
|
|
207
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/health', expect.any(Object))
|
|
208
|
+
expect(result).toBe(true)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should return true after delay if no health check configured', async () => {
|
|
212
|
+
const service: ServiceConfig = {
|
|
213
|
+
name: 'api',
|
|
214
|
+
port: 3000,
|
|
215
|
+
domains: ['api.example.com'],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const result = await waitForService(service, 5000)
|
|
219
|
+
|
|
220
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
221
|
+
expect(result).toBe(true)
|
|
222
|
+
}, 10000) // Increase test timeout to account for 5 second delay
|
|
223
|
+
|
|
224
|
+
it('should return false if health check times out', async () => {
|
|
225
|
+
const service: ServiceConfig = {
|
|
226
|
+
name: 'api',
|
|
227
|
+
port: 3000,
|
|
228
|
+
domains: ['api.example.com'],
|
|
229
|
+
healthCheck: {
|
|
230
|
+
path: '/health',
|
|
231
|
+
interval: 30,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
mockFetch.mockRejectedValue(new Error('Network error'))
|
|
236
|
+
|
|
237
|
+
const result = await waitForService(service, 1000)
|
|
238
|
+
|
|
239
|
+
expect(result).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|