go-duck-cli 1.3.0 → 1.3.3
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/generators/ai_docs.js +20 -11
- package/generators/devops.js +130 -20
- package/generators/postman.js +21 -18
- package/generators/swagger.js +8 -5
- package/package.json +1 -1
- package/templates/docs/pages/index.hbs +10 -0
package/generators/ai_docs.js
CHANGED
|
@@ -5,6 +5,9 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
5
5
|
const aiDocsDir = path.join(outputDir, 'docs', 'ai');
|
|
6
6
|
await fs.ensureDir(aiDocsDir);
|
|
7
7
|
|
|
8
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
9
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
10
|
+
|
|
8
11
|
// 1. ARCHITECTURE.md
|
|
9
12
|
const appName = config.name || 'go-duck-app';
|
|
10
13
|
const hasMongo = config.datasource?.mongodb?.enabled;
|
|
@@ -32,7 +35,7 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
32
35
|
archContent += `- **Active**: ${storageActive ? 'Yes' : 'No'}\n`;
|
|
33
36
|
if (storageActive) {
|
|
34
37
|
archContent += `- **Enabled Nodes**: ${Object.keys(config.storage).filter(k => config.storage[k]?.enabled).join(', ')}\n`;
|
|
35
|
-
archContent += `- **Endpoints**: \n - Upload: \`POST /
|
|
38
|
+
archContent += `- **Endpoints**: \n - Upload: \`POST ${apiPrefix}/storage/upload?provider=\`\n - Exact Retrieve: \`GET ${apiPrefix}/storage/download/*key?provider=\`\n - Cross-Scan Locate: \`GET ${apiPrefix}/storage/scan/*key\`\n`;
|
|
36
39
|
}
|
|
37
40
|
const bootstrapActive = config.storage?.bootstrap?.enabled;
|
|
38
41
|
if (bootstrapActive) {
|
|
@@ -43,29 +46,35 @@ export const generateAIDocs = async (config, entities, outputDir, enums, openEnt
|
|
|
43
46
|
archContent += `- **Elasticsearch Sync**: ${config.search?.elasticsearch?.enabled ? 'Enabled (via @Searchable)' : 'Disabled'}\n`;
|
|
44
47
|
archContent += `- **OpenTelemetry**: ${config.telemetry?.otel?.enabled ? 'Enabled' : 'Disabled'}\n`;
|
|
45
48
|
|
|
49
|
+
archContent += `\n## Deployment (Kubernetes)\n`;
|
|
50
|
+
archContent += `- **Network Isolation**: Strict per-service isolated namespaces (e.g., \`[shortName]-[service]-k8s\`).\n`;
|
|
51
|
+
archContent += `- **Internal Routing**: Services communicate via explicit Fully Qualified Domain Names (FQDN).\n`;
|
|
52
|
+
archContent += `- **External Exposure**: Infrastructure services are exposed via explicit NodePort mappings (Range: 30000+).\n`;
|
|
53
|
+
archContent += `- **Self-Contained Manifests**: All manifests inject their own \`kind: Namespace\` block for auto-provisioning.\n`;
|
|
54
|
+
|
|
46
55
|
await fs.writeFile(path.join(aiDocsDir, 'ARCHITECTURE.md'), archContent);
|
|
47
56
|
|
|
48
57
|
// 2. ENDPOINTS.md
|
|
49
58
|
let endpointsContent = `# REST & gRPC API Surface\n\n`;
|
|
50
|
-
endpointsContent += `## Base Path:
|
|
59
|
+
endpointsContent += `## Base Path: \`${apiPrefix}\`\n\n`;
|
|
51
60
|
endpointsContent += `### Standard Entity Endpoints\n`;
|
|
52
61
|
for (const entity of entities) {
|
|
53
62
|
const routeName = entity.name.toLowerCase() + 's';
|
|
54
63
|
endpointsContent += `\n#### ${entity.name}\n`;
|
|
55
|
-
endpointsContent += `- \`GET
|
|
56
|
-
endpointsContent += `- \`GET
|
|
57
|
-
endpointsContent += `- \`POST
|
|
58
|
-
endpointsContent += `- \`PUT
|
|
59
|
-
endpointsContent += `- \`DELETE
|
|
60
|
-
endpointsContent += `- \`POST
|
|
64
|
+
endpointsContent += `- \`GET ${apiPrefix}/${routeName}\` (Pagination & dynamic sorting, e.g. \`?page=1&size=10&eager=true&sort=id,asc\`)\n`;
|
|
65
|
+
endpointsContent += `- \`GET ${apiPrefix}/${routeName}/:id\`\n`;
|
|
66
|
+
endpointsContent += `- \`POST ${apiPrefix}/${routeName}\`\n`;
|
|
67
|
+
endpointsContent += `- \`PUT ${apiPrefix}/${routeName}/:id\`\n`;
|
|
68
|
+
endpointsContent += `- \`DELETE ${apiPrefix}/${routeName}/:id\`\n`;
|
|
69
|
+
endpointsContent += `- \`POST ${apiPrefix}/${routeName}/bulk\` (Bulk Create/Update)\n`;
|
|
61
70
|
if (entity.isSearchable) {
|
|
62
|
-
endpointsContent += `- \`GET /
|
|
71
|
+
endpointsContent += `- \`GET ${apiPrefix}/search/${routeName}?q={query}\` (Elasticsearch)\n`;
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
endpointsContent += `\n## Infrastructure & Management APIs (SuperAdmin Protected)\n`;
|
|
67
|
-
endpointsContent += `- \`GET /
|
|
68
|
-
endpointsContent += `- \`POST /
|
|
76
|
+
endpointsContent += `- \`GET ${apiPrefix}/admin/audit\` (Fetches Global Delta logs from the Centralized Audit Engine)\n`;
|
|
77
|
+
endpointsContent += `- \`POST ${apiPrefix}/admin/metering/limit\` (Set SaaS Quota limits)\n`;
|
|
69
78
|
endpointsContent += `- \`POST /management/tenant/assign\` (Dynamically initialize new Tenant DBs)\n`;
|
|
70
79
|
|
|
71
80
|
endpointsContent += `\n## Open APIs (No JWT required)\n`;
|
package/generators/devops.js
CHANGED
|
@@ -14,6 +14,8 @@ export const generateDeploymentArtifacts = async (config, projectRootDir) => {
|
|
|
14
14
|
await fs.ensureDir(githubDir);
|
|
15
15
|
|
|
16
16
|
const appName = config.name || 'go-duck';
|
|
17
|
+
const parts = appName.split(/[-_]/);
|
|
18
|
+
const shortName = (parts.length > 1 ? parts.map(w => w[0]).join('') : appName).substring(0, 10).toLowerCase() || 'goduck';
|
|
17
19
|
const appPort = config.server?.port || 8080;
|
|
18
20
|
const keycloakHost = config.security?.['keycloak-host'] || 'http://localhost:8180';
|
|
19
21
|
const keycloakPort = keycloakHost.includes(':') ? keycloakHost.split(':').pop() : '8080';
|
|
@@ -117,6 +119,7 @@ services:
|
|
|
117
119
|
mosquitto:
|
|
118
120
|
image: eclipse-mosquitto:2.0.18
|
|
119
121
|
container_name: ${appName}-mqtt
|
|
122
|
+
command: ["sh", "-c", "mosquitto_passwd -c -b /tmp/mosquitto_passwd ${config.messaging?.mqtt?.username || 'dev_user'} ${config.messaging?.mqtt?.password || 'dev_password'} && chown 1883:1883 /tmp/mosquitto_passwd && exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf"]
|
|
120
123
|
ports:
|
|
121
124
|
- "${mqttPort}:1883"
|
|
122
125
|
- "9001:9001"
|
|
@@ -207,10 +210,26 @@ services:
|
|
|
207
210
|
- GO_DUCK_DATASOURCE_PASSWORD=${config.datasource?.password || 'password'}
|
|
208
211
|
- GO_DUCK_DATASOURCE_DATABASE=${config.datasource?.database || 'go_duck_master'}
|
|
209
212
|
- GO_DUCK_DATASOURCE_PORT=5432
|
|
213
|
+
- GO_DUCK_DATASOURCE_MONGODB_ENABLED=${config.datasource?.mongodb?.enabled ? 'true' : 'false'}
|
|
214
|
+
- GO_DUCK_DATASOURCE_MONGODB_URI=mongodb://mongodb:27017
|
|
215
|
+
- GO_DUCK_DATASOURCE_MONGODB_DATABASE=${config.datasource?.mongodb?.database || 'goduck_document_store'}
|
|
210
216
|
- GO_DUCK_CACHE_REDIS_HOST=redis:6379
|
|
217
|
+
- GO_DUCK_CACHE_REDIS_PASSWORD=${config.cache?.redis?.password || ''}
|
|
211
218
|
- GO_DUCK_MESSAGING_MQTT_BROKER=tcp://mosquitto:1883
|
|
219
|
+
- GO_DUCK_MESSAGING_MQTT_USERNAME=${config.messaging?.mqtt?.username || 'dev_user'}
|
|
220
|
+
- GO_DUCK_MESSAGING_MQTT_PASSWORD=${config.messaging?.mqtt?.password || 'dev_password'}
|
|
212
221
|
- GO_DUCK_TELEMETRY_OTEL_ENDPOINT=otel-collector:4317
|
|
213
222
|
- GO_DUCK_ELASTICSEARCH_ADDRESSES=http://elasticsearch:9200
|
|
223
|
+
- GO_DUCK_ELASTICSEARCH_USERNAME=${config.elasticsearch?.username || 'elastic'}
|
|
224
|
+
- GO_DUCK_ELASTICSEARCH_PASSWORD=${config.elasticsearch?.password || 'changeme'}
|
|
225
|
+
- GO_DUCK_SECURITY_KEYCLOAK_HOST=http://keycloak:8080
|
|
226
|
+
- GO_DUCK_SECURITY_KEYCLOAK_REALM=${config.security?.['keycloak-realm'] || 'go-duck-realm'}
|
|
227
|
+
- GO_DUCK_SECURITY_KEYCLOAK_APP_CLIENT_ID=${config.security?.['keycloak-app-client-id'] || 'go-duck-app'}
|
|
228
|
+
- GO_DUCK_SECURITY_KEYCLOAK_SERVICE_CLIENT_ID=${config.security?.['keycloak-service-client-id'] || 'go-duck-service'}
|
|
229
|
+
- GO_DUCK_SECURITY_KEYCLOAK_SERVICE_SECRET=${config.security?.['keycloak-service-secret'] || 'service-secret-123'}
|
|
230
|
+
- GO_DUCK_STORAGE_MINIO_ENDPOINT=http://minio:9000
|
|
231
|
+
- GO_DUCK_STORAGE_MINIO_ACCESS_KEY=${config.storage?.minio?.['access-key'] || 'minioadmin'}
|
|
232
|
+
- GO_DUCK_STORAGE_MINIO_SECRET_KEY=${config.storage?.minio?.['secret-key'] || 'minioadmin'}
|
|
214
233
|
restart: always
|
|
215
234
|
networks:
|
|
216
235
|
- go-duck-net
|
|
@@ -241,10 +260,26 @@ services:
|
|
|
241
260
|
- GO_DUCK_DATASOURCE_PASSWORD=${config.datasource?.password || 'password'}
|
|
242
261
|
- GO_DUCK_DATASOURCE_DATABASE=${config.datasource?.database || 'go_duck_master'}
|
|
243
262
|
- GO_DUCK_DATASOURCE_PORT=5432
|
|
263
|
+
- GO_DUCK_DATASOURCE_MONGODB_ENABLED=${config.datasource?.mongodb?.enabled ? 'true' : 'false'}
|
|
264
|
+
- GO_DUCK_DATASOURCE_MONGODB_URI=mongodb://mongodb:27017
|
|
265
|
+
- GO_DUCK_DATASOURCE_MONGODB_DATABASE=${config.datasource?.mongodb?.database || 'goduck_document_store'}
|
|
244
266
|
- GO_DUCK_CACHE_REDIS_HOST=redis:6379
|
|
267
|
+
- GO_DUCK_CACHE_REDIS_PASSWORD=${config.cache?.redis?.password || ''}
|
|
245
268
|
- GO_DUCK_MESSAGING_MQTT_BROKER=tcp://mosquitto:1883
|
|
269
|
+
- GO_DUCK_MESSAGING_MQTT_USERNAME=${config.messaging?.mqtt?.username || 'dev_user'}
|
|
270
|
+
- GO_DUCK_MESSAGING_MQTT_PASSWORD=${config.messaging?.mqtt?.password || 'dev_password'}
|
|
246
271
|
- GO_DUCK_TELEMETRY_OTEL_ENDPOINT=otel-collector:4317
|
|
247
272
|
- GO_DUCK_ELASTICSEARCH_ADDRESSES=http://elasticsearch:9200
|
|
273
|
+
- GO_DUCK_ELASTICSEARCH_USERNAME=${config.elasticsearch?.username || 'elastic'}
|
|
274
|
+
- GO_DUCK_ELASTICSEARCH_PASSWORD=${config.elasticsearch?.password || 'changeme'}
|
|
275
|
+
- GO_DUCK_SECURITY_KEYCLOAK_HOST=http://keycloak:8080
|
|
276
|
+
- GO_DUCK_SECURITY_KEYCLOAK_REALM=${config.security?.['keycloak-realm'] || 'go-duck-realm'}
|
|
277
|
+
- GO_DUCK_SECURITY_KEYCLOAK_APP_CLIENT_ID=${config.security?.['keycloak-app-client-id'] || 'go-duck-app'}
|
|
278
|
+
- GO_DUCK_SECURITY_KEYCLOAK_SERVICE_CLIENT_ID=${config.security?.['keycloak-service-client-id'] || 'go-duck-service'}
|
|
279
|
+
- GO_DUCK_SECURITY_KEYCLOAK_SERVICE_SECRET=${config.security?.['keycloak-service-secret'] || 'service-secret-123'}
|
|
280
|
+
- GO_DUCK_STORAGE_MINIO_ENDPOINT=http://minio:9000
|
|
281
|
+
- GO_DUCK_STORAGE_MINIO_ACCESS_KEY=${config.storage?.minio?.['access-key'] || 'minioadmin'}
|
|
282
|
+
- GO_DUCK_STORAGE_MINIO_SECRET_KEY=${config.storage?.minio?.['secret-key'] || 'minioadmin'}
|
|
248
283
|
depends_on:
|
|
249
284
|
postgres:
|
|
250
285
|
condition: service_healthy
|
|
@@ -269,7 +304,8 @@ networks:
|
|
|
269
304
|
listener 1883
|
|
270
305
|
listener 9001
|
|
271
306
|
protocol websockets
|
|
272
|
-
allow_anonymous
|
|
307
|
+
allow_anonymous false
|
|
308
|
+
password_file /tmp/mosquitto_passwd
|
|
273
309
|
`;
|
|
274
310
|
|
|
275
311
|
// --- 6. GitHub Actions CI/CD ---
|
|
@@ -454,7 +490,7 @@ spec:
|
|
|
454
490
|
- name: GO_DUCK_SERVER_REST_PORT
|
|
455
491
|
value: "${appPort}"
|
|
456
492
|
- name: GO_DUCK_DATASOURCE_HOST
|
|
457
|
-
value: postgres
|
|
493
|
+
value: postgres.${shortName}-postgres-k8s.svc.cluster.local
|
|
458
494
|
- name: GO_DUCK_DATASOURCE_USERNAME
|
|
459
495
|
value: "${config.datasource?.username || 'postgres'}"
|
|
460
496
|
- name: GO_DUCK_DATASOURCE_PASSWORD
|
|
@@ -463,18 +499,48 @@ spec:
|
|
|
463
499
|
value: "${config.datasource?.database || 'go_duck_master'}"
|
|
464
500
|
- name: GO_DUCK_DATASOURCE_PORT
|
|
465
501
|
value: "5432"
|
|
502
|
+
- name: GO_DUCK_DATASOURCE_MONGODB_ENABLED
|
|
503
|
+
value: "${config.datasource?.mongodb?.enabled ? 'true' : 'false'}"
|
|
504
|
+
- name: GO_DUCK_DATASOURCE_MONGODB_URI
|
|
505
|
+
value: mongodb://mongodb.${shortName}-mongodb-k8s.svc.cluster.local:27017
|
|
506
|
+
- name: GO_DUCK_DATASOURCE_MONGODB_DATABASE
|
|
507
|
+
value: "${config.datasource?.mongodb?.database || 'goduck_document_store'}"
|
|
466
508
|
- name: GO_DUCK_CACHE_REDIS_HOST
|
|
467
|
-
value: redis:6379
|
|
509
|
+
value: redis.${shortName}-redis-k8s.svc.cluster.local:6379
|
|
510
|
+
- name: GO_DUCK_CACHE_REDIS_PASSWORD
|
|
511
|
+
value: "${config.cache?.redis?.password || ''}"
|
|
468
512
|
- name: GO_DUCK_MESSAGING_MQTT_BROKER
|
|
469
|
-
value: tcp://mosquitto:1883
|
|
513
|
+
value: tcp://mosquitto.${shortName}-mosquitto-k8s.svc.cluster.local:1883
|
|
514
|
+
- name: GO_DUCK_MESSAGING_MQTT_USERNAME
|
|
515
|
+
value: "${config.messaging?.mqtt?.username || 'dev_user'}"
|
|
516
|
+
- name: GO_DUCK_MESSAGING_MQTT_PASSWORD
|
|
517
|
+
value: "${config.messaging?.mqtt?.password || 'dev_password'}"
|
|
470
518
|
- name: GO_DUCK_MESSAGING_NATS_URL
|
|
471
|
-
value: nats://nats:4222
|
|
519
|
+
value: nats://nats.${shortName}-nats-k8s.svc.cluster.local:4222
|
|
472
520
|
- name: GO_DUCK_TELEMETRY_OTEL_ENDPOINT
|
|
473
|
-
value: otel-collector:4317
|
|
521
|
+
value: otel-collector.${shortName}-otel-collector-k8s.svc.cluster.local:4317
|
|
474
522
|
- name: GO_DUCK_ELASTICSEARCH_ADDRESSES
|
|
475
|
-
value: http://elasticsearch:9200
|
|
523
|
+
value: http://elasticsearch.${shortName}-elasticsearch-k8s.svc.cluster.local:9200
|
|
524
|
+
- name: GO_DUCK_ELASTICSEARCH_USERNAME
|
|
525
|
+
value: "${config.elasticsearch?.username || 'elastic'}"
|
|
526
|
+
- name: GO_DUCK_ELASTICSEARCH_PASSWORD
|
|
527
|
+
value: "${config.elasticsearch?.password || 'changeme'}"
|
|
476
528
|
- name: GO_DUCK_SECURITY_KEYCLOAK_HOST
|
|
477
|
-
value: http://keycloak:8080
|
|
529
|
+
value: http://keycloak.${shortName}-keycloak-k8s.svc.cluster.local:8080
|
|
530
|
+
- name: GO_DUCK_SECURITY_KEYCLOAK_REALM
|
|
531
|
+
value: "${config.security?.['keycloak-realm'] || 'go-duck-realm'}"
|
|
532
|
+
- name: GO_DUCK_SECURITY_KEYCLOAK_APP_CLIENT_ID
|
|
533
|
+
value: "${config.security?.['keycloak-app-client-id'] || 'go-duck-app'}"
|
|
534
|
+
- name: GO_DUCK_SECURITY_KEYCLOAK_SERVICE_CLIENT_ID
|
|
535
|
+
value: "${config.security?.['keycloak-service-client-id'] || 'go-duck-service'}"
|
|
536
|
+
- name: GO_DUCK_SECURITY_KEYCLOAK_SERVICE_SECRET
|
|
537
|
+
value: "${config.security?.['keycloak-service-secret'] || 'service-secret-123'}"
|
|
538
|
+
- name: GO_DUCK_STORAGE_MINIO_ENDPOINT
|
|
539
|
+
value: "http://minio.${shortName}-minio-k8s.svc.cluster.local:9000"
|
|
540
|
+
- name: GO_DUCK_STORAGE_MINIO_ACCESS_KEY
|
|
541
|
+
value: "${config.storage?.minio?.['access-key'] || 'minioadmin'}"
|
|
542
|
+
- name: GO_DUCK_STORAGE_MINIO_SECRET_KEY
|
|
543
|
+
value: "${config.storage?.minio?.['secret-key'] || 'minioadmin'}"
|
|
478
544
|
ports:
|
|
479
545
|
- name: http
|
|
480
546
|
containerPort: ${appPort}
|
|
@@ -486,13 +552,16 @@ kind: Service
|
|
|
486
552
|
metadata:
|
|
487
553
|
name: ${appName}
|
|
488
554
|
spec:
|
|
555
|
+
type: NodePort
|
|
489
556
|
ports:
|
|
490
557
|
- name: http
|
|
491
558
|
port: ${appPort}
|
|
492
559
|
targetPort: http
|
|
560
|
+
nodePort: 30080
|
|
493
561
|
- name: grpc
|
|
494
562
|
port: 9000
|
|
495
563
|
targetPort: grpc
|
|
564
|
+
nodePort: 30090
|
|
496
565
|
selector:
|
|
497
566
|
app: ${appName}
|
|
498
567
|
`;
|
|
@@ -549,8 +618,10 @@ kind: Service
|
|
|
549
618
|
metadata:
|
|
550
619
|
name: postgres
|
|
551
620
|
spec:
|
|
621
|
+
type: NodePort
|
|
552
622
|
ports:
|
|
553
623
|
- port: 5432
|
|
624
|
+
nodePort: 30432
|
|
554
625
|
selector:
|
|
555
626
|
app: postgres
|
|
556
627
|
`;
|
|
@@ -582,8 +653,10 @@ kind: Service
|
|
|
582
653
|
metadata:
|
|
583
654
|
name: redis
|
|
584
655
|
spec:
|
|
656
|
+
type: NodePort
|
|
585
657
|
ports:
|
|
586
658
|
- port: 6379
|
|
659
|
+
nodePort: 30379
|
|
587
660
|
selector:
|
|
588
661
|
app: redis
|
|
589
662
|
`;
|
|
@@ -615,8 +688,10 @@ kind: Service
|
|
|
615
688
|
metadata:
|
|
616
689
|
name: nats
|
|
617
690
|
spec:
|
|
691
|
+
type: NodePort
|
|
618
692
|
ports:
|
|
619
693
|
- port: 4222
|
|
694
|
+
nodePort: 30222
|
|
620
695
|
selector:
|
|
621
696
|
app: nats
|
|
622
697
|
`;
|
|
@@ -630,7 +705,8 @@ data:
|
|
|
630
705
|
listener 1883
|
|
631
706
|
listener 9001
|
|
632
707
|
protocol websockets
|
|
633
|
-
allow_anonymous
|
|
708
|
+
allow_anonymous false
|
|
709
|
+
password_file /tmp/mosquitto_passwd
|
|
634
710
|
---
|
|
635
711
|
apiVersion: apps/v1
|
|
636
712
|
kind: Deployment
|
|
@@ -651,6 +727,7 @@ spec:
|
|
|
651
727
|
containers:
|
|
652
728
|
- name: mosquitto
|
|
653
729
|
image: eclipse-mosquitto:2.0.18
|
|
730
|
+
command: ["sh", "-c", "mosquitto_passwd -c -b /tmp/mosquitto_passwd ${config.messaging?.mqtt?.username || 'dev_user'} ${config.messaging?.mqtt?.password || 'dev_password'} && chown 1883:1883 /tmp/mosquitto_passwd && exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf"]
|
|
654
731
|
ports:
|
|
655
732
|
- containerPort: 1883
|
|
656
733
|
- containerPort: 9001
|
|
@@ -668,11 +745,14 @@ kind: Service
|
|
|
668
745
|
metadata:
|
|
669
746
|
name: mosquitto
|
|
670
747
|
spec:
|
|
748
|
+
type: NodePort
|
|
671
749
|
ports:
|
|
672
750
|
- name: mqtt
|
|
673
751
|
port: 1883
|
|
752
|
+
nodePort: 31883
|
|
674
753
|
- name: ws
|
|
675
754
|
port: 9001
|
|
755
|
+
nodePort: 30001
|
|
676
756
|
selector:
|
|
677
757
|
app: mosquitto
|
|
678
758
|
`;
|
|
@@ -711,8 +791,10 @@ kind: Service
|
|
|
711
791
|
metadata:
|
|
712
792
|
name: elasticsearch
|
|
713
793
|
spec:
|
|
794
|
+
type: NodePort
|
|
714
795
|
ports:
|
|
715
796
|
- port: 9200
|
|
797
|
+
nodePort: 30200
|
|
716
798
|
selector:
|
|
717
799
|
app: elasticsearch
|
|
718
800
|
`;
|
|
@@ -748,11 +830,14 @@ kind: Service
|
|
|
748
830
|
metadata:
|
|
749
831
|
name: jaeger
|
|
750
832
|
spec:
|
|
833
|
+
type: NodePort
|
|
751
834
|
ports:
|
|
752
835
|
- name: ui
|
|
753
836
|
port: 16686
|
|
837
|
+
nodePort: 30686
|
|
754
838
|
- name: otlp-grpc
|
|
755
839
|
port: 4317
|
|
840
|
+
nodePort: 30317
|
|
756
841
|
selector:
|
|
757
842
|
app: jaeger
|
|
758
843
|
`;
|
|
@@ -824,11 +909,14 @@ kind: Service
|
|
|
824
909
|
metadata:
|
|
825
910
|
name: otel-collector
|
|
826
911
|
spec:
|
|
912
|
+
type: NodePort
|
|
827
913
|
ports:
|
|
828
914
|
- name: otlp-grpc
|
|
829
915
|
port: 4317
|
|
916
|
+
nodePort: 30317
|
|
830
917
|
- name: otlp-http
|
|
831
918
|
port: 4318
|
|
919
|
+
nodePort: 30318
|
|
832
920
|
selector:
|
|
833
921
|
app: otel-collector
|
|
834
922
|
`;
|
|
@@ -890,8 +978,10 @@ kind: Service
|
|
|
890
978
|
metadata:
|
|
891
979
|
name: keycloak
|
|
892
980
|
spec:
|
|
981
|
+
type: NodePort
|
|
893
982
|
ports:
|
|
894
983
|
- port: 8080
|
|
984
|
+
nodePort: 30880
|
|
895
985
|
selector:
|
|
896
986
|
app: keycloak
|
|
897
987
|
`;
|
|
@@ -909,17 +999,33 @@ spec:
|
|
|
909
999
|
await fs.writeFile(path.join(devopsDir, 'app.yml'), appYaml);
|
|
910
1000
|
await fs.writeFile(path.join(devopsDir, 'docker-compose.yml'), dockerCompose);
|
|
911
1001
|
await fs.writeFile(path.join(k8sDir, 'mosquitto.conf'), mosquittoConf);
|
|
1002
|
+
// Namespace calculation: unique namespace per service
|
|
1003
|
+
const applyNs = (yamlString, serviceName) => {
|
|
1004
|
+
const nsName = `${shortName}-${serviceName}-k8s`;
|
|
1005
|
+
const nsBlock = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n---\n`;
|
|
1006
|
+
return nsBlock + yamlString.replace(/^metadata:$/gm, `metadata:\n namespace: ${nsName}`);
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const servicesList = ['app', 'postgres', 'redis', 'nats', 'mosquitto', 'elasticsearch', 'jaeger', 'otel-collector', 'keycloak'];
|
|
1010
|
+
if (config.datasource?.mongodb?.enabled) servicesList.push('mongodb');
|
|
912
1011
|
|
|
1012
|
+
// We conditionally add minio if storage is enabled (or any blob provider), but for safety we'll just generate its namespace always.
|
|
1013
|
+
servicesList.push('minio');
|
|
1014
|
+
|
|
1015
|
+
const k8sNamespaceYaml = servicesList.map(svc => `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${shortName}-${svc}-k8s`).join('\n---\n');
|
|
1016
|
+
|
|
1017
|
+
await fs.writeFile(path.join(k8sDir, 'namespace.yaml'), k8sNamespaceYaml);
|
|
1018
|
+
|
|
913
1019
|
// Write Kubernetes manifest assets
|
|
914
|
-
await fs.writeFile(path.join(k8sDir, 'app.yaml'), k8sAppYaml);
|
|
915
|
-
await fs.writeFile(path.join(k8sDir, 'postgres.yaml'), k8sPostgresYaml);
|
|
916
|
-
await fs.writeFile(path.join(k8sDir, 'redis.yaml'), k8sRedisYaml);
|
|
917
|
-
await fs.writeFile(path.join(k8sDir, 'nats.yaml'), k8sNatsYaml);
|
|
918
|
-
await fs.writeFile(path.join(k8sDir, 'mosquitto.yaml'), k8sMosquittoYaml);
|
|
919
|
-
await fs.writeFile(path.join(k8sDir, 'elasticsearch.yaml'), k8sElasticsearchYaml);
|
|
920
|
-
await fs.writeFile(path.join(k8sDir, 'jaeger.yaml'), k8sJaegerYaml);
|
|
921
|
-
await fs.writeFile(path.join(k8sDir, 'otel-collector-k8s.yaml'), k8sOtelCollectorYaml);
|
|
922
|
-
await fs.writeFile(path.join(k8sDir, 'keycloak.yaml'), k8sKeycloakYaml);
|
|
1020
|
+
await fs.writeFile(path.join(k8sDir, 'app.yaml'), applyNs(k8sAppYaml, 'app'));
|
|
1021
|
+
await fs.writeFile(path.join(k8sDir, 'postgres.yaml'), applyNs(k8sPostgresYaml, 'postgres'));
|
|
1022
|
+
await fs.writeFile(path.join(k8sDir, 'redis.yaml'), applyNs(k8sRedisYaml, 'redis'));
|
|
1023
|
+
await fs.writeFile(path.join(k8sDir, 'nats.yaml'), applyNs(k8sNatsYaml, 'nats'));
|
|
1024
|
+
await fs.writeFile(path.join(k8sDir, 'mosquitto.yaml'), applyNs(k8sMosquittoYaml, 'mosquitto'));
|
|
1025
|
+
await fs.writeFile(path.join(k8sDir, 'elasticsearch.yaml'), applyNs(k8sElasticsearchYaml, 'elasticsearch'));
|
|
1026
|
+
await fs.writeFile(path.join(k8sDir, 'jaeger.yaml'), applyNs(k8sJaegerYaml, 'jaeger'));
|
|
1027
|
+
await fs.writeFile(path.join(k8sDir, 'otel-collector-k8s.yaml'), applyNs(k8sOtelCollectorYaml, 'otel-collector'));
|
|
1028
|
+
await fs.writeFile(path.join(k8sDir, 'keycloak.yaml'), applyNs(k8sKeycloakYaml, 'keycloak'));
|
|
923
1029
|
|
|
924
1030
|
// Additional optional services manifests
|
|
925
1031
|
const k8sMongoYaml = `apiVersion: apps/v1
|
|
@@ -949,8 +1055,10 @@ kind: Service
|
|
|
949
1055
|
metadata:
|
|
950
1056
|
name: mongodb
|
|
951
1057
|
spec:
|
|
1058
|
+
type: NodePort
|
|
952
1059
|
ports:
|
|
953
1060
|
- port: 27017
|
|
1061
|
+
nodePort: 30017
|
|
954
1062
|
selector:
|
|
955
1063
|
app: mongodb`;
|
|
956
1064
|
const k8sMinioYaml = `apiVersion: apps/v1
|
|
@@ -988,12 +1096,14 @@ kind: Service
|
|
|
988
1096
|
metadata:
|
|
989
1097
|
name: minio
|
|
990
1098
|
spec:
|
|
1099
|
+
type: NodePort
|
|
991
1100
|
ports:
|
|
992
1101
|
- port: 9000
|
|
1102
|
+
nodePort: 30900
|
|
993
1103
|
selector:
|
|
994
1104
|
app: minio`;
|
|
995
|
-
await fs.writeFile(path.join(k8sDir, 'mongodb.yaml'), k8sMongoYaml);
|
|
996
|
-
await fs.writeFile(path.join(k8sDir, 'minio.yaml'), k8sMinioYaml);
|
|
1105
|
+
await fs.writeFile(path.join(k8sDir, 'mongodb.yaml'), applyNs(k8sMongoYaml, 'mongodb'));
|
|
1106
|
+
await fs.writeFile(path.join(k8sDir, 'minio.yaml'), applyNs(k8sMinioYaml, 'minio'));
|
|
997
1107
|
|
|
998
1108
|
await fs.writeFile(path.join(githubDir, 'ci.yml'), ciWorkflow);
|
|
999
1109
|
await fs.writeFile(path.join(githubDir, 'cd.yml'), cdWorkflow);
|
package/generators/postman.js
CHANGED
|
@@ -4,6 +4,9 @@ import chalk from 'chalk';
|
|
|
4
4
|
|
|
5
5
|
export const generatePostmanCollection = async (config, entities, outputDir, openEntities = []) => {
|
|
6
6
|
const docsDir = path.join(outputDir, 'docs');
|
|
7
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
8
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
9
|
+
const apiPathArr = apiPrefix.split('/').filter(p => p !== '');
|
|
7
10
|
await fs.ensureDir(docsDir);
|
|
8
11
|
|
|
9
12
|
const collection = {
|
|
@@ -140,11 +143,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
140
143
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
141
144
|
],
|
|
142
145
|
url: {
|
|
143
|
-
raw:
|
|
146
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/admin/audit`,
|
|
144
147
|
protocol: "http",
|
|
145
148
|
host: ["{{host}}"],
|
|
146
149
|
port: "{{port}}",
|
|
147
|
-
path: [
|
|
150
|
+
path: [...apiPathArr, "admin", "audit"]
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
},
|
|
@@ -157,11 +160,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
157
160
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
158
161
|
],
|
|
159
162
|
url: {
|
|
160
|
-
raw:
|
|
163
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/metering/usage`,
|
|
161
164
|
protocol: "http",
|
|
162
165
|
host: ["{{host}}"],
|
|
163
166
|
port: "{{port}}",
|
|
164
|
-
path: [
|
|
167
|
+
path: [...apiPathArr, "metering", "usage"]
|
|
165
168
|
}
|
|
166
169
|
}
|
|
167
170
|
}
|
|
@@ -390,7 +393,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
390
393
|
request: {
|
|
391
394
|
method: "GET",
|
|
392
395
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
393
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
396
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
394
397
|
}
|
|
395
398
|
});
|
|
396
399
|
publicItems.push({
|
|
@@ -398,7 +401,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
398
401
|
request: {
|
|
399
402
|
method: "GET",
|
|
400
403
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
401
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
404
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`, "1"] }
|
|
402
405
|
}
|
|
403
406
|
});
|
|
404
407
|
}
|
|
@@ -409,7 +412,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
409
412
|
method: "POST",
|
|
410
413
|
header: [ { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
411
414
|
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
412
|
-
url: { raw: `http://{{host}}:{{port}}/open
|
|
415
|
+
url: { raw: `http://{{host}}:{{port}}/open${apiPrefix}/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: ["open", ...apiPathArr, `${name}s`] }
|
|
413
416
|
}
|
|
414
417
|
});
|
|
415
418
|
}
|
|
@@ -425,7 +428,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
425
428
|
request: {
|
|
426
429
|
method: "GET",
|
|
427
430
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
428
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
431
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s?page=1&size=10&eager=true&sort=id,asc`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`], query: [ { key: "page", value: "1" }, { key: "size", value: "10" }, { key: "eager", value: "true" }, { key: "sort", value: "id,asc" } ] }
|
|
429
432
|
}
|
|
430
433
|
},
|
|
431
434
|
{
|
|
@@ -434,7 +437,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
434
437
|
method: "POST",
|
|
435
438
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" }, { key: "Content-Type", value: "application/json" } ],
|
|
436
439
|
body: { mode: "raw", raw: generateDummyJson(entity.fields) },
|
|
437
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
440
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`] }
|
|
438
441
|
}
|
|
439
442
|
},
|
|
440
443
|
{
|
|
@@ -442,7 +445,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
442
445
|
request: {
|
|
443
446
|
method: "GET",
|
|
444
447
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
445
|
-
url: { raw: `http://{{host}}:{{port}}
|
|
448
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/${name}s/1`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, `${name}s`, "1"] }
|
|
446
449
|
}
|
|
447
450
|
},
|
|
448
451
|
{
|
|
@@ -450,7 +453,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
450
453
|
request: {
|
|
451
454
|
method: "GET",
|
|
452
455
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
453
|
-
url: { raw: `http://{{host}}:{{port}}/
|
|
456
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/rpc/${name}?id=gt.0`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, "rpc", name], query: [ { key: "id", value: "gt.0" } ] }
|
|
454
457
|
}
|
|
455
458
|
}
|
|
456
459
|
];
|
|
@@ -461,7 +464,7 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
461
464
|
request: {
|
|
462
465
|
method: "GET",
|
|
463
466
|
header: [ { key: "Authorization", value: "Bearer {{token}}" }, { key: "X-Tenant-ID", value: "{{tenant}}" } ],
|
|
464
|
-
url: { raw: `http://{{host}}:{{port}}/
|
|
467
|
+
url: { raw: `http://{{host}}:{{port}}${apiPrefix}/search/${name}?q=Sample`, protocol: "http", host: ["{{host}}"], port: "{{port}}", path: [...apiPathArr, "search", name], query: [ { key: "q", value: "Sample" } ] }
|
|
465
468
|
}
|
|
466
469
|
});
|
|
467
470
|
}
|
|
@@ -513,11 +516,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
513
516
|
]
|
|
514
517
|
},
|
|
515
518
|
url: {
|
|
516
|
-
raw:
|
|
519
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/upload?provider=sftp`,
|
|
517
520
|
protocol: "http",
|
|
518
521
|
host: ["{{host}}"],
|
|
519
522
|
port: "{{port}}",
|
|
520
|
-
path: [
|
|
523
|
+
path: [...apiPathArr, "storage", "upload"],
|
|
521
524
|
query: [{ key: "provider", value: "sftp" }]
|
|
522
525
|
}
|
|
523
526
|
}
|
|
@@ -531,11 +534,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
531
534
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
532
535
|
],
|
|
533
536
|
url: {
|
|
534
|
-
raw:
|
|
537
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/download/farm/animals/photo.jpg?provider=sftp`,
|
|
535
538
|
protocol: "http",
|
|
536
539
|
host: ["{{host}}"],
|
|
537
540
|
port: "{{port}}",
|
|
538
|
-
path: [
|
|
541
|
+
path: [...apiPathArr, "storage", "download", "farm", "animals", "photo.jpg"],
|
|
539
542
|
query: [{ key: "provider", value: "sftp" }]
|
|
540
543
|
}
|
|
541
544
|
}
|
|
@@ -549,11 +552,11 @@ export const generatePostmanCollection = async (config, entities, outputDir, ope
|
|
|
549
552
|
{ key: "X-Tenant-ID", value: "{{tenant}}" }
|
|
550
553
|
],
|
|
551
554
|
url: {
|
|
552
|
-
raw:
|
|
555
|
+
raw: `http://{{host}}:{{port}}${apiPrefix}/storage/scan/farm/animals/photo.jpg`,
|
|
553
556
|
protocol: "http",
|
|
554
557
|
host: ["{{host}}"],
|
|
555
558
|
port: "{{port}}",
|
|
556
|
-
path: [
|
|
559
|
+
path: [...apiPathArr, "storage", "scan", "farm", "animals", "photo.jpg"]
|
|
557
560
|
}
|
|
558
561
|
}
|
|
559
562
|
}
|
package/generators/swagger.js
CHANGED
|
@@ -6,6 +6,9 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
6
6
|
const docsDir = path.join(outputDir, 'docs');
|
|
7
7
|
await fs.ensureDir(docsDir);
|
|
8
8
|
|
|
9
|
+
const apiPrefixRaw = config.server?.rest?.['api-path-prefix'] || '/api';
|
|
10
|
+
const apiPrefix = apiPrefixRaw.endsWith('/') ? apiPrefixRaw.slice(0, -1) : apiPrefixRaw;
|
|
11
|
+
|
|
9
12
|
const swagger = {
|
|
10
13
|
openapi: '3.0.0',
|
|
11
14
|
info: {
|
|
@@ -201,14 +204,14 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
201
204
|
};
|
|
202
205
|
|
|
203
206
|
// 1a. Secured Paths
|
|
204
|
-
addEntityOperations(
|
|
207
|
+
addEntityOperations(apiPrefix, false);
|
|
205
208
|
|
|
206
209
|
// 1b. Public Paths (if marked as open)
|
|
207
|
-
addEntityOperations('/open
|
|
210
|
+
addEntityOperations('/open' + apiPrefix, true);
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
// 2. Add System Paths
|
|
211
|
-
swagger.paths[
|
|
214
|
+
swagger.paths[`${apiPrefix}/rpc/{table}`] = {
|
|
212
215
|
get: {
|
|
213
216
|
tags: ['Search Engine'],
|
|
214
217
|
summary: 'Generic PostgREST RPC Engine',
|
|
@@ -240,7 +243,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
240
243
|
}
|
|
241
244
|
};
|
|
242
245
|
|
|
243
|
-
swagger.paths[
|
|
246
|
+
swagger.paths[`${apiPrefix}/admin/audit`] = {
|
|
244
247
|
get: {
|
|
245
248
|
tags: ['Observability'],
|
|
246
249
|
summary: 'Fetch Audit Trail',
|
|
@@ -281,7 +284,7 @@ export const generateSwaggerDocs = async (config, entities, outputDir, openEntit
|
|
|
281
284
|
};
|
|
282
285
|
|
|
283
286
|
if (config.elasticsearch?.enabled) {
|
|
284
|
-
swagger.paths[
|
|
287
|
+
swagger.paths[`${apiPrefix}/search/{entity}`] = {
|
|
285
288
|
get: {
|
|
286
289
|
tags: ['Search Engine'],
|
|
287
290
|
summary: 'Elasticsearch Global Search',
|
package/package.json
CHANGED
|
@@ -120,6 +120,16 @@
|
|
|
120
120
|
<span class="px-4 py-1.5 bg-orange-100 text-orange-700 text-[9px] font-black rounded-lg">WSO2 REST PROXY</span>
|
|
121
121
|
</div>
|
|
122
122
|
</div>
|
|
123
|
+
|
|
124
|
+
<div class="lg:col-span-12 bg-white p-10 rounded-[2.5rem] border border-fuchsia-100 shadow-sm hover:shadow-2xl transition-all group relative overflow-hidden cursor-crosshair bg-gradient-to-br from-white to-fuchsia-50/30">
|
|
125
|
+
<p class="text-[9px] font-bold text-fuchsia-600 uppercase tracking-widest mb-4">Elite Extension (+12%)</p>
|
|
126
|
+
<h4 class="text-2xl font-black text-slate-900 mb-3 tracking-tight italic">K8s Network Isolation & NodePorts</h4>
|
|
127
|
+
<p class="text-slate-600 leading-relaxed mb-8">Deploys every microservice component into its own dynamically generated namespace. Uses internal FQDN routing for secure communication and predictable NodePorts for host access.</p>
|
|
128
|
+
<div class="flex gap-3">
|
|
129
|
+
<span class="px-4 py-1.5 bg-fuchsia-100 text-fuchsia-700 text-[9px] font-black rounded-lg">ISOLATED NAMESPACES</span>
|
|
130
|
+
<span class="px-4 py-1.5 bg-fuchsia-100 text-fuchsia-700 text-[9px] font-black rounded-lg">FQDN ROUTING</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
123
133
|
</div>
|
|
124
134
|
</div>
|
|
125
135
|
</section>
|