@gadmin2n/schematics 0.0.79 → 0.0.81

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.
@@ -0,0 +1,270 @@
1
+ # Graceful Deployment 方案
2
+
3
+ 本文档描述项目在 K8s 环境下实现零停机滚动升级(Graceful Rolling Update)的完整方案。
4
+
5
+ ---
6
+
7
+ ## 1. 问题背景
8
+
9
+ 在 K8s 多 Pod 部署(Ingress + Service)中,镜像升级/重启时如果没有 graceful shutdown 机制,会导致:
10
+ - 正在处理的请求被强制中断(502/504)
11
+ - 定时任务执行到一半被杀死
12
+ - 数据库连接、Temporal 连接未正确释放
13
+
14
+ ---
15
+
16
+ ## 2. 整体时序
17
+
18
+ ```
19
+ K8s 发起 Pod 删除(Deployment rolling update)
20
+
21
+ ├─► [异步1] 从 Service Endpoints 摘除 Pod(kube-proxy 更新 iptables)
22
+ │ → 新流量不再路由到此 Pod
23
+
24
+ └─► [异步2] 执行 lifecycle.preStop hook
25
+
26
+ └─► sleep 5s(等待 iptables 规则同步到所有节点)
27
+
28
+ └─► 发送 SIGTERM 给容器主进程
29
+
30
+ ├─► NestJS server.close()(停止接受新 TCP 连接)
31
+ ├─► 处理完所有 in-flight HTTP 请求
32
+ ├─► 触发 onApplicationShutdown() 生命周期钩子:
33
+ │ ├─► AgendaService: 停止接取新任务,等待当前任务完成
34
+ │ ├─► TemporalService: 断开 Temporal 连接
35
+ │ └─► OpenTelemetry: flush 追踪数据
36
+ └─► 进程退出,容器结束
37
+
38
+ ═══════════════════════════════════════════════════════════════
39
+ terminationGracePeriodSeconds(300s)超时后 → 强制 SIGKILL
40
+ ```
41
+
42
+ **关键点:** `preStop sleep 5s` 和 `SIGTERM` 是串行的,但与 Endpoints 摘除是并行的。sleep 确保在进程开始关闭前,iptables 已完成更新,避免流量打到正在关闭的 Pod。
43
+
44
+ ---
45
+
46
+ ## 3. 代码改动
47
+
48
+ ### 3.1 `server/src/main.ts` — 启用 Shutdown Hooks
49
+
50
+ ```typescript
51
+ // 在 app.listen() 之前
52
+ app.enableShutdownHooks();
53
+ ```
54
+
55
+ **作用:** 使 NestJS 在收到 SIGTERM 时触发所有 `onApplicationShutdown()` / `onModuleDestroy()` 生命周期钩子。没有此行,所有清理逻辑都不会被执行。
56
+
57
+ ### 3.2 `Dockerfile` — STOPSIGNAL 对齐
58
+
59
+ ```diff
60
+ - STOPSIGNAL SIGQUIT
61
+ + STOPSIGNAL SIGTERM
62
+ ```
63
+
64
+ **作用:** 与 K8s 默认发送的 SIGTERM 及 NestJS `enableShutdownHooks()` 监听的信号保持一致。
65
+
66
+ ### 3.3 新增 Health Module
67
+
68
+ | 端点 | 用途 | 响应 |
69
+ |------|------|------|
70
+ | `GET /{DEPLOY_NAME}/api/health/live` | K8s livenessProbe | `{ "status": "ok" }` |
71
+ | `GET /{DEPLOY_NAME}/api/health/ready` | K8s readinessProbe | `{ "status": "ok" }` |
72
+
73
+ 两个端点均通过 `@AllowUnauthorizedRequest()` 跳过认证,可被 K8s kubelet 直接访问。
74
+
75
+ ### 3.4 AgendaService — 优雅关闭
76
+
77
+ `AgendaService.onApplicationShutdown()` 在收到 SIGTERM 后被触发:
78
+
79
+ ```typescript
80
+ async onApplicationShutdown() {
81
+ await this.agenda.stop(); // 停止接取新任务,等待当前 running jobs 完成
82
+ this.logger.log('Agenda stopped');
83
+ }
84
+ ```
85
+
86
+ **`agenda.stop()` 的行为:**
87
+ - 停止从 `agenda_jobs` 表 lock 新任务
88
+ - 等待所有正在执行的 job handler 的 Promise resolve
89
+ - 不会等待未来计划的任务(其他 Pod 会接管)
90
+
91
+ **注意:** 如果未来有执行时间可能超过 295s(300 - 5s preStop)的任务,需要在 job handler 中增加检查点机制提前退出,或加大 `terminationGracePeriodSeconds`。
92
+
93
+ ---
94
+
95
+ ## 4. Spinnaker / K8s Deployment 配置
96
+
97
+ 以下配置需在 Spinnaker 管理的 Deployment manifest 中修改:
98
+
99
+ ```yaml
100
+ apiVersion: apps/v1
101
+ kind: Deployment
102
+ metadata:
103
+ name: gadmin-server
104
+ spec:
105
+ # ─── 滚动升级策略 ───────────────────────────────────────────
106
+ strategy:
107
+ type: RollingUpdate
108
+ rollingUpdate:
109
+ maxSurge: 1 # 先启动 1 个新 Pod
110
+ maxUnavailable: 0 # 零停机:旧 Pod 不提前终止
111
+
112
+ template:
113
+ spec:
114
+ # ─── 优雅关闭宽限期 ─────────────────────────────────────
115
+ terminationGracePeriodSeconds: 300
116
+ # 给 NestJS 足够时间完成:
117
+ # - in-flight HTTP 请求
118
+ # - 正在执行的 Agenda 定时任务(最长可能数分钟)
119
+ # - OpenTelemetry trace flush
120
+ # 超过 300s 未退出将被 SIGKILL 强杀
121
+
122
+ containers:
123
+ - name: server
124
+ image: <registry>/<image>:<tag>
125
+ ports:
126
+ - containerPort: 8000
127
+
128
+ # ─── preStop Hook ───────────────────────────────────
129
+ lifecycle:
130
+ preStop:
131
+ exec:
132
+ command: ["sh", "-c", "sleep 5"]
133
+ # 目的:等待 kube-proxy 完成 iptables 规则同步
134
+ # 确保不会有新流量在 SIGTERM 发出后仍然打到此 Pod
135
+
136
+ # ─── Readiness Probe ────────────────────────────────
137
+ # Pod 通过此探针后才会被加入 Service Endpoints 接收流量
138
+ readinessProbe:
139
+ httpGet:
140
+ path: /<DEPLOY_NAME>/api/health/ready
141
+ port: 8000
142
+ initialDelaySeconds: 5
143
+ periodSeconds: 5
144
+ failureThreshold: 3
145
+ successThreshold: 1
146
+
147
+ # ─── Liveness Probe ─────────────────────────────────
148
+ # 探针失败后 K8s 会重启容器(检测进程死锁/僵死)
149
+ livenessProbe:
150
+ httpGet:
151
+ path: /<DEPLOY_NAME>/api/health/live
152
+ port: 8000
153
+ initialDelaySeconds: 15
154
+ periodSeconds: 10
155
+ failureThreshold: 3
156
+ ```
157
+
158
+ > **注意:** `<DEPLOY_NAME>` 替换为实际部署名称(如 `gadmin-test`),对应 `process.env.DEPLOY_NAME`。
159
+
160
+ ---
161
+
162
+ ## 5. 配置说明
163
+
164
+ ### 5.1 为什么 terminationGracePeriodSeconds = 300?
165
+
166
+ | 因素 | 说明 |
167
+ |------|------|
168
+ | preStop sleep | 5s |
169
+ | In-flight HTTP 请求处理 | 通常 < 10s |
170
+ | Agenda 定时任务完成 | 可能长达数分钟 |
171
+ | OpenTelemetry flush | < 5s |
172
+ | **总计余量** | 300s 覆盖大多数场景 |
173
+
174
+ `agenda.stop()` 会等待当前正在执行的 job 完成。如果 job 执行时间超过 295s(300 - 5s preStop),将被 SIGKILL 强杀。建议:
175
+ - 单个 job 执行时间控制在 4 分钟以内
176
+ - 超长任务使用 `isStopping` 检查点机制提前退出
177
+
178
+ ### 5.2 为什么 preStop sleep 5 秒?
179
+
180
+ K8s 删除 Pod 时,Endpoints 摘除和 preStop/SIGTERM 是**并行**触发的:
181
+ - Endpoints 控制器通知各节点 kube-proxy 更新 iptables 规则需要时间(通常 1-3 秒)
182
+ - 如果不 sleep,SIGTERM 可能在 iptables 更新完成前就让进程开始关闭
183
+ - 5 秒是业界推荐值,足以覆盖绝大部分集群的传播延迟
184
+
185
+ ### 5.3 maxUnavailable: 0 的意义
186
+
187
+ 确保滚动升级过程中**始终有足够 Pod 处理流量**:
188
+ 1. 先启动新 Pod(maxSurge: 1)
189
+ 2. 新 Pod readinessProbe 通过后加入 Service
190
+ 3. 旧 Pod 开始 graceful shutdown
191
+ 4. 旧 Pod 完全退出后再终止下一个
192
+
193
+ ---
194
+
195
+ ## 6. 验证方法
196
+
197
+ ### 6.1 本地验证 Shutdown Hooks
198
+
199
+ ```bash
200
+ cd server && yarn start:prod &
201
+ SERVER_PID=$!
202
+
203
+ # 等服务启动
204
+ sleep 3
205
+
206
+ # 发送 SIGTERM
207
+ kill -TERM $SERVER_PID
208
+
209
+ # 观察日志应输出:
210
+ # "Agenda stopped"
211
+ # 进程正常退出(exit code 0)
212
+ ```
213
+
214
+ ### 6.2 验证 Health 端点
215
+
216
+ ```bash
217
+ # 启动后请求
218
+ curl http://localhost:8000/<DEPLOY_NAME>/api/health/live
219
+ # 期望:{"status":"ok"}
220
+
221
+ curl http://localhost:8000/<DEPLOY_NAME>/api/health/ready
222
+ # 期望:{"status":"ok"}
223
+ ```
224
+
225
+ ### 6.3 K8s 环境验证
226
+
227
+ ```bash
228
+ # 观察滚动升级过程
229
+ kubectl rollout status deployment/gadmin-server -w
230
+
231
+ # 查看 Pod 事件(确认 preStop 执行)
232
+ kubectl describe pod <pod-name> | grep -A5 "Events"
233
+
234
+ # 升级期间持续请求验证零中断
235
+ while true; do curl -s -o /dev/null -w "%{http_code}\n" http://<ingress>/api/health/ready; sleep 0.5; done
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 7. 信号传递链路
241
+
242
+ ```
243
+ K8s kubelet
244
+
245
+ └─► Docker/containerd: 发送 STOPSIGNAL (SIGTERM)
246
+
247
+ └─► start-prod.sh(使用 exec,PID 1 是 node 进程)
248
+
249
+ └─► Node.js process
250
+
251
+ ├─► NestJS enableShutdownHooks() 捕获 SIGTERM
252
+ │ └─► 调用 app.close()
253
+ │ └─► 触发所有生命周期钩子
254
+
255
+ └─► OpenTelemetry process.on('SIGTERM') handler
256
+ └─► flush traces
257
+ ```
258
+
259
+ `start-prod.sh` 中的 `exec node dist/src/main` 确保 Node.js 进程是容器的 PID 1,信号不会被 shell 拦截。
260
+
261
+ ---
262
+
263
+ ## 8. 故障场景与应对
264
+
265
+ | 场景 | 现象 | 应对 |
266
+ |------|------|------|
267
+ | Job 超时被 SIGKILL | 任务中断,锁超时后释放 | 其他 Pod 自动重试(Agenda 分布式锁) |
268
+ | readinessProbe 失败 | Pod 从 Service 摘除 | 不接收新流量,等 liveness 判定是否重启 |
269
+ | preStop 未配置 | 升级时短暂 502 | 添加 preStop sleep 5 |
270
+ | enableShutdownHooks 未启用 | 资源泄漏(连接未关闭) | 已通过本次改造修复 |
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@agendajs/postgres-backend": "^3.0.5",
38
38
  "@azure/identity": "^4.13.0",
39
- "@gadmin2n/nest-common": "^0.0.49",
39
+ "@gadmin2n/nest-common": "^0.0.51",
40
40
  "@nestjs/cache-manager": "^3.0.1",
41
41
  "@nestjs/common": "^10.4.15",
42
42
  "@nestjs/config": "^3.2.0",
@@ -88,8 +88,8 @@
88
88
  },
89
89
  "devDependencies": {
90
90
  "@faker-js/faker": "^10.4.0",
91
- "@gadmin2n/prisma-nest-generator": "^0.0.42",
92
- "@gadmin2n/prisma-react-generator": "^0.0.58",
91
+ "@gadmin2n/prisma-nest-generator": "^0.0.44",
92
+ "@gadmin2n/prisma-react-generator": "^0.0.60",
93
93
  "@nestjs/testing": "^10.4.15",
94
94
  "@types/cookie-parser": "^1.4.3",
95
95
  "@types/express": "^4.17.21",
@@ -11,7 +11,7 @@
11
11
  "@dnd-kit/sortable": "^7.0.2",
12
12
  "@dnd-kit/utilities": "^3.2.2",
13
13
  "@gadmin2n/charts": "^0.0.7",
14
- "@gadmin2n/react-common": "^0.0.68",
14
+ "@gadmin2n/react-common": "^0.0.70",
15
15
  "@monaco-editor/react": "^4.7.0",
16
16
  "@refinedev/antd": "^5.47.0",
17
17
  "@refinedev/cli": "^2.16.51",
@@ -30,7 +30,12 @@ export interface ExecutionStatusNodeData {
30
30
  category: string;
31
31
  isSelected: boolean;
32
32
  readonly?: boolean;
33
- executionStatus?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'NOT_EXECUTED';
33
+ executionStatus?:
34
+ | 'PENDING'
35
+ | 'RUNNING'
36
+ | 'COMPLETED'
37
+ | 'FAILED'
38
+ | 'NOT_EXECUTED';
34
39
  duration?: string; // e.g., "2.5s"
35
40
  error?: string;
36
41
  [key: string]: unknown;
@@ -175,20 +180,21 @@ export const ExecutionStatusNode = memo(({ data }: NodeProps) => {
175
180
  )}
176
181
 
177
182
  {/* Execution status text */}
178
- {nodeData.executionStatus && nodeData.executionStatus !== 'NOT_EXECUTED' && (
179
- <div
180
- style={{
181
- fontSize: 11,
182
- color: nodeData.executionStatus === 'FAILED' ? '#ff4d4f' : '#666',
183
- fontWeight: 500,
184
- }}
185
- >
186
- {nodeData.executionStatus === 'COMPLETED' && 'Completed'}
187
- {nodeData.executionStatus === 'RUNNING' && 'Running...'}
188
- {nodeData.executionStatus === 'FAILED' && 'Failed'}
189
- {nodeData.executionStatus === 'PENDING' && 'Pending'}
190
- </div>
191
- )}
183
+ {nodeData.executionStatus &&
184
+ nodeData.executionStatus !== 'NOT_EXECUTED' && (
185
+ <div
186
+ style={{
187
+ fontSize: 11,
188
+ color: nodeData.executionStatus === 'FAILED' ? '#ff4d4f' : '#666',
189
+ fontWeight: 500,
190
+ }}
191
+ >
192
+ {nodeData.executionStatus === 'COMPLETED' && 'Completed'}
193
+ {nodeData.executionStatus === 'RUNNING' && 'Running...'}
194
+ {nodeData.executionStatus === 'FAILED' && 'Failed'}
195
+ {nodeData.executionStatus === 'PENDING' && 'Pending'}
196
+ </div>
197
+ )}
192
198
 
193
199
  {!nodeData.readonly && (
194
200
  <Handle
@@ -207,9 +207,10 @@ export default function WorkflowInstanceDetailPage() {
207
207
  const selectedExecution = selectedNodeId
208
208
  ? data.nodeExecutions.find((ne) => ne.nodeId === selectedNodeId)
209
209
  : null;
210
- const selectedDslNode = selectedNodeId && data.dsl
211
- ? data.dsl.nodes.find((n) => n.id === selectedNodeId)
212
- : null;
210
+ const selectedDslNode =
211
+ selectedNodeId && data.dsl
212
+ ? data.dsl.nodes.find((n) => n.id === selectedNodeId)
213
+ : null;
213
214
 
214
215
  return (
215
216
  <div
@@ -347,7 +348,9 @@ export default function WorkflowInstanceDetailPage() {
347
348
  title: (
348
349
  <Space
349
350
  style={{ cursor: 'pointer' }}
350
- onClick={() => setSelectedNodeId(isSelected ? null : ne.nodeId)}
351
+ onClick={() =>
352
+ setSelectedNodeId(isSelected ? null : ne.nodeId)
353
+ }
351
354
  >
352
355
  <span style={{ fontWeight: isSelected ? 600 : 400 }}>
353
356
  {ne.nodeLabel || ne.nodeId}
@@ -358,7 +361,10 @@ export default function WorkflowInstanceDetailPage() {
358
361
  <Button
359
362
  size="small"
360
363
  type="primary"
361
- onClick={(e) => { e.stopPropagation(); handleApprove(ne.nodeId, true); }}
364
+ onClick={(e) => {
365
+ e.stopPropagation();
366
+ handleApprove(ne.nodeId, true);
367
+ }}
362
368
  loading={actionLoading}
363
369
  >
364
370
  Approve
@@ -366,7 +372,10 @@ export default function WorkflowInstanceDetailPage() {
366
372
  <Button
367
373
  size="small"
368
374
  danger
369
- onClick={(e) => { e.stopPropagation(); handleApprove(ne.nodeId, false); }}
375
+ onClick={(e) => {
376
+ e.stopPropagation();
377
+ handleApprove(ne.nodeId, false);
378
+ }}
370
379
  loading={actionLoading}
371
380
  >
372
381
  Reject
@@ -377,8 +386,14 @@ export default function WorkflowInstanceDetailPage() {
377
386
  ),
378
387
  description: (
379
388
  <div
380
- style={{ fontSize: 12, color: '#666', cursor: 'pointer' }}
381
- onClick={() => setSelectedNodeId(isSelected ? null : ne.nodeId)}
389
+ style={{
390
+ fontSize: 12,
391
+ color: '#666',
392
+ cursor: 'pointer',
393
+ }}
394
+ onClick={() =>
395
+ setSelectedNodeId(isSelected ? null : ne.nodeId)
396
+ }
382
397
  >
383
398
  <span>
384
399
  Duration:{' '}
@@ -396,7 +411,9 @@ export default function WorkflowInstanceDetailPage() {
396
411
  ),
397
412
  status: getStepStatus(ne.status),
398
413
  icon:
399
- ne.status === 'RUNNING' ? <LoadingOutlined /> : undefined,
414
+ ne.status === 'RUNNING' ? (
415
+ <LoadingOutlined />
416
+ ) : undefined,
400
417
  };
401
418
  })}
402
419
  />
@@ -432,7 +449,11 @@ export default function WorkflowInstanceDetailPage() {
432
449
  </Tag>
433
450
  </div>
434
451
 
435
- <Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
452
+ <Descriptions
453
+ column={1}
454
+ size="small"
455
+ style={{ marginBottom: 16 }}
456
+ >
436
457
  <Descriptions.Item label="Node Type">
437
458
  {selectedExecution.nodeType}
438
459
  </Descriptions.Item>
@@ -447,14 +468,28 @@ export default function WorkflowInstanceDetailPage() {
447
468
  : '—'}
448
469
  </Descriptions.Item>
449
470
  <Descriptions.Item label="Duration">
450
- {formatDuration(selectedExecution.startedAt, selectedExecution.finishedAt)}
471
+ {formatDuration(
472
+ selectedExecution.startedAt,
473
+ selectedExecution.finishedAt,
474
+ )}
451
475
  </Descriptions.Item>
452
476
  </Descriptions>
453
477
 
454
478
  {selectedExecution.input && (
455
479
  <div style={{ marginBottom: 12 }}>
456
- <Text strong style={{ fontSize: 12 }}>Input:</Text>
457
- <pre style={{ background: '#fafafa', padding: 8, borderRadius: 4, fontSize: 11, maxHeight: 150, overflow: 'auto' }}>
480
+ <Text strong style={{ fontSize: 12 }}>
481
+ Input:
482
+ </Text>
483
+ <pre
484
+ style={{
485
+ background: '#fafafa',
486
+ padding: 8,
487
+ borderRadius: 4,
488
+ fontSize: 11,
489
+ maxHeight: 150,
490
+ overflow: 'auto',
491
+ }}
492
+ >
458
493
  {JSON.stringify(selectedExecution.input, null, 2)}
459
494
  </pre>
460
495
  </div>
@@ -462,8 +497,19 @@ export default function WorkflowInstanceDetailPage() {
462
497
 
463
498
  {selectedExecution.output && (
464
499
  <div style={{ marginBottom: 12 }}>
465
- <Text strong style={{ fontSize: 12 }}>Output:</Text>
466
- <pre style={{ background: '#fafafa', padding: 8, borderRadius: 4, fontSize: 11, maxHeight: 150, overflow: 'auto' }}>
500
+ <Text strong style={{ fontSize: 12 }}>
501
+ Output:
502
+ </Text>
503
+ <pre
504
+ style={{
505
+ background: '#fafafa',
506
+ padding: 8,
507
+ borderRadius: 4,
508
+ fontSize: 11,
509
+ maxHeight: 150,
510
+ overflow: 'auto',
511
+ }}
512
+ >
467
513
  {JSON.stringify(selectedExecution.output, null, 2)}
468
514
  </pre>
469
515
  </div>
@@ -471,8 +517,20 @@ export default function WorkflowInstanceDetailPage() {
471
517
 
472
518
  {selectedExecution.error && (
473
519
  <div style={{ marginBottom: 12 }}>
474
- <Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>Error:</Text>
475
- <pre style={{ background: '#fff1f0', padding: 8, borderRadius: 4, fontSize: 11, color: '#ff4d4f', maxHeight: 150, overflow: 'auto' }}>
520
+ <Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>
521
+ Error:
522
+ </Text>
523
+ <pre
524
+ style={{
525
+ background: '#fff1f0',
526
+ padding: 8,
527
+ borderRadius: 4,
528
+ fontSize: 11,
529
+ color: '#ff4d4f',
530
+ maxHeight: 150,
531
+ overflow: 'auto',
532
+ }}
533
+ >
476
534
  {typeof selectedExecution.error === 'object'
477
535
  ? JSON.stringify(selectedExecution.error, null, 2)
478
536
  : String(selectedExecution.error)}
@@ -596,14 +654,21 @@ export default function WorkflowInstanceDetailPage() {
596
654
  overflow: 'auto',
597
655
  }}
598
656
  >
599
- {JSON.stringify(selectedExecution.output, null, 2)}
657
+ {JSON.stringify(
658
+ selectedExecution.output,
659
+ null,
660
+ 2,
661
+ )}
600
662
  </pre>
601
663
  </div>
602
664
  )}
603
665
 
604
666
  {selectedExecution.error && (
605
667
  <div style={{ marginBottom: 12 }}>
606
- <Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>
668
+ <Text
669
+ strong
670
+ style={{ fontSize: 12, color: '#ff4d4f' }}
671
+ >
607
672
  Error:
608
673
  </Text>
609
674
  <pre
@@ -618,7 +683,11 @@ export default function WorkflowInstanceDetailPage() {
618
683
  }}
619
684
  >
620
685
  {typeof selectedExecution.error === 'object'
621
- ? JSON.stringify(selectedExecution.error, null, 2)
686
+ ? JSON.stringify(
687
+ selectedExecution.error,
688
+ null,
689
+ 2,
690
+ )
622
691
  : String(selectedExecution.error)}
623
692
  </pre>
624
693
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.79",
3
+ "version": "0.0.81",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [