@aifabrix/builder 2.44.4 → 2.44.6
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/.cursor/rules/cli-layout.mdc +1 -1
- package/.cursor/rules/project-rules.mdc +1 -1
- package/.npmrc.token +1 -1
- package/README.md +15 -23
- package/integration/hubspot-test/README.md +2 -0
- package/integration/hubspot-test/test.js +5 -3
- package/jest.projects.js +68 -17
- package/lib/api/controller-health.api.js +49 -0
- package/lib/api/dimension-values.api.js +82 -0
- package/lib/api/dimensions.api.js +114 -0
- package/lib/api/external-systems.api.js +1 -0
- package/lib/api/integration-clients.api.js +168 -0
- package/lib/api/types/dimension-values.types.js +28 -0
- package/lib/api/types/dimensions.types.js +31 -0
- package/lib/api/types/integration-clients.types.js +45 -0
- package/lib/api/types/wizard.types.js +2 -1
- package/lib/api/validation-runner.js +46 -25
- package/lib/app/deploy-config.js +11 -1
- package/lib/app/deploy-status-display.js +3 -3
- package/lib/app/deploy.js +36 -14
- package/lib/app/display.js +15 -11
- package/lib/app/push.js +46 -23
- package/lib/app/register.js +1 -1
- package/lib/app/restart-display.js +95 -0
- package/lib/app/rotate-secret.js +1 -1
- package/lib/app/run-container-start.js +12 -6
- package/lib/app/run-env-compose.js +30 -1
- package/lib/app/run-helpers.js +44 -12
- package/lib/app/run-reload-sync.js +148 -0
- package/lib/app/run-resolve-image.js +51 -1
- package/lib/app/run.js +99 -73
- package/lib/build/index.js +75 -45
- package/lib/cli/doctor-check.js +117 -0
- package/lib/cli/index.js +8 -2
- package/lib/cli/infra-guided.js +445 -0
- package/lib/cli/setup-app.help.js +1 -1
- package/lib/cli/setup-app.js +20 -2
- package/lib/cli/setup-app.test-commands.js +9 -5
- package/lib/cli/setup-auth.js +26 -0
- package/lib/cli/setup-dev-path-commands.js +50 -3
- package/lib/cli/setup-infra.js +138 -61
- package/lib/cli/setup-integration-client.js +182 -0
- package/lib/cli/setup-parameters.js +21 -2
- package/lib/cli/setup-platform.js +102 -0
- package/lib/cli/setup-secrets.js +18 -6
- package/lib/cli/setup-utility.js +97 -33
- package/lib/commands/datasource-capability-dimension-cli.js +128 -0
- package/lib/commands/datasource-capability-output.js +29 -0
- package/lib/commands/datasource-capability-relate-cli.js +140 -0
- package/lib/commands/datasource-capability.js +411 -0
- package/lib/commands/datasource-unified-test-cli.options.js +1 -1
- package/lib/commands/datasource.js +53 -13
- package/lib/commands/dev-down.js +3 -3
- package/lib/commands/dev-infra-gate.js +32 -0
- package/lib/commands/dev-init.js +13 -7
- package/lib/commands/dimension-value.js +179 -0
- package/lib/commands/dimension.js +330 -0
- package/lib/commands/integration-client.js +430 -0
- package/lib/commands/login-device.js +65 -30
- package/lib/commands/login.js +21 -10
- package/lib/commands/parameters-validate.js +78 -13
- package/lib/commands/repair-datasource-auto-rbac.js +166 -0
- package/lib/commands/repair-datasource-keys.js +10 -5
- package/lib/commands/repair-datasource.js +19 -7
- package/lib/commands/repair-env-template.js +4 -1
- package/lib/commands/repair-openapi-sync.js +172 -0
- package/lib/commands/repair-persist.js +102 -0
- package/lib/commands/repair-rbac-extract.js +27 -0
- package/lib/commands/repair-rbac-migrate.js +186 -0
- package/lib/commands/repair-rbac.js +225 -19
- package/lib/commands/repair-system-alignment.js +246 -0
- package/lib/commands/repair-system-permissions.js +168 -0
- package/lib/commands/repair.js +120 -354
- package/lib/commands/secure.js +1 -1
- package/lib/commands/setup-modes.js +455 -0
- package/lib/commands/setup-prompts.js +388 -0
- package/lib/commands/setup.js +149 -0
- package/lib/commands/teardown.js +228 -0
- package/lib/commands/test-e2e-external.js +4 -3
- package/lib/commands/up-common.js +97 -12
- package/lib/commands/up-dataplane.js +33 -11
- package/lib/commands/up-miso.js +7 -11
- package/lib/commands/upload.js +109 -23
- package/lib/commands/wizard-core-helpers.js +14 -11
- package/lib/commands/wizard-core.js +58 -15
- package/lib/commands/wizard-dataplane.js +2 -2
- package/lib/commands/wizard-entity-selection.js +72 -14
- package/lib/commands/wizard-headless.js +7 -3
- package/lib/commands/wizard-helpers.js +13 -1
- package/lib/commands/wizard.js +210 -61
- package/lib/constants/infra-compose-service-names.js +40 -0
- package/lib/core/env-reader.js +16 -3
- package/lib/core/secrets-admin-env.js +101 -0
- package/lib/core/secrets-ensure-infra.js +34 -1
- package/lib/core/secrets-ensure.js +88 -66
- package/lib/core/secrets-env-content.js +432 -0
- package/lib/core/secrets-env-write.js +27 -1
- package/lib/core/secrets-load.js +248 -0
- package/lib/core/secrets-names.js +32 -0
- package/lib/core/secrets.js +17 -757
- package/lib/datasource/capability/basic-exposure.js +76 -0
- package/lib/datasource/capability/capability-diff-slice.js +41 -0
- package/lib/datasource/capability/capability-key.js +34 -0
- package/lib/datasource/capability/capability-resolve.js +172 -0
- package/lib/datasource/capability/capability-storage-keys.js +22 -0
- package/lib/datasource/capability/copy-operations.js +348 -0
- package/lib/datasource/capability/copy-test-payload.js +139 -0
- package/lib/datasource/capability/create-operations.js +235 -0
- package/lib/datasource/capability/dimension-operations.js +151 -0
- package/lib/datasource/capability/dimension-validate.js +219 -0
- package/lib/datasource/capability/json-pointer.js +31 -0
- package/lib/datasource/capability/reference-rewrite.js +51 -0
- package/lib/datasource/capability/relate-operations.js +325 -0
- package/lib/datasource/capability/relate-validate.js +219 -0
- package/lib/datasource/capability/remove-operations.js +275 -0
- package/lib/datasource/capability/run-capability-copy.js +152 -0
- package/lib/datasource/capability/run-capability-diff.js +135 -0
- package/lib/datasource/capability/run-capability-dimension.js +291 -0
- package/lib/datasource/capability/run-capability-edit.js +377 -0
- package/lib/datasource/capability/run-capability-relate.js +193 -0
- package/lib/datasource/capability/run-capability-remove.js +105 -0
- package/lib/datasource/capability/templates/minimal-fetch.json +18 -0
- package/lib/datasource/capability/validate-capability-slice.js +35 -0
- package/lib/datasource/list.js +136 -23
- package/lib/datasource/log-viewer.js +2 -4
- package/lib/datasource/unified-validation-run.js +51 -16
- package/lib/datasource/validate.js +53 -1
- package/lib/deployment/deploy-poll-ui.js +60 -0
- package/lib/deployment/deployer-status.js +29 -3
- package/lib/deployment/deployer.js +48 -30
- package/lib/deployment/environment.js +7 -2
- package/lib/deployment/poll-interval.js +72 -0
- package/lib/deployment/push.js +11 -9
- package/lib/external-system/deploy.js +4 -1
- package/lib/external-system/download.js +61 -32
- package/lib/external-system/sync-deploy-manifest.js +33 -0
- package/lib/generator/wizard-prompts.js +7 -1
- package/lib/generator/wizard.js +34 -0
- package/lib/infrastructure/index.js +49 -19
- package/lib/infrastructure/orphan-infra-docker-teardown.js +177 -0
- package/lib/parameters/infra-kv-discovery.js +29 -4
- package/lib/parameters/infra-parameter-catalog.js +6 -3
- package/lib/parameters/infra-parameter-validate.js +67 -19
- package/lib/resolvers/datasource-resolver.js +53 -0
- package/lib/resolvers/dimension-file.js +52 -0
- package/lib/resolvers/manifest-resolver.js +133 -0
- package/lib/schema/external-datasource.schema.json +183 -53
- package/lib/schema/external-system.schema.json +23 -10
- package/lib/schema/infra.parameter.yaml +26 -11
- package/lib/schema/wizard-config.schema.json +2 -2
- package/lib/utils/aifabrix-config-dir-walk.js +40 -0
- package/lib/utils/aifabrix-runtime-config-dir.js +26 -3
- package/lib/utils/app-run-containers.js +2 -2
- package/lib/utils/bash-secret-env.js +59 -0
- package/lib/utils/cli-secrets-error-format.js +78 -0
- package/lib/utils/cli-test-layout-chalk.js +31 -9
- package/lib/utils/cli-utils.js +4 -36
- package/lib/utils/datasource-test-run-display.js +8 -0
- package/lib/utils/dev-hosts-helper.js +3 -2
- package/lib/utils/dev-init-ssh-merge.js +2 -1
- package/lib/utils/docker-build.js +17 -9
- package/lib/utils/docker-reload-mount.js +127 -0
- package/lib/utils/external-readme.js +117 -4
- package/lib/utils/external-system-local-test-tty.js +3 -2
- package/lib/utils/external-system-readiness-core.js +45 -12
- package/lib/utils/external-system-readiness-deploy-display.js +3 -3
- package/lib/utils/external-system-readiness-display-internals.js +33 -3
- package/lib/utils/external-system-readiness-display.js +10 -1
- package/lib/utils/file-upload.js +40 -3
- package/lib/utils/health-check-db-init.js +107 -0
- package/lib/utils/health-check-public-warn.js +69 -0
- package/lib/utils/health-check-url.js +19 -4
- package/lib/utils/health-check.js +135 -105
- package/lib/utils/help-builder.js +5 -1
- package/lib/utils/image-name.js +34 -7
- package/lib/utils/integration-file-backup.js +74 -0
- package/lib/utils/mutagen-install.js +30 -3
- package/lib/utils/paths.js +108 -25
- package/lib/utils/postgres-wipe.js +212 -0
- package/lib/utils/register-aifabrix-shell-env.js +15 -0
- package/lib/utils/remote-dev-auth.js +21 -5
- package/lib/utils/remote-docker-env.js +9 -1
- package/lib/utils/remote-secrets-loader.js +42 -3
- package/lib/utils/resolve-docker-image-ref.js +9 -3
- package/lib/utils/secrets-ancestor-paths.js +47 -0
- package/lib/utils/secrets-helpers.js +17 -10
- package/lib/utils/secrets-kv-refs.js +42 -0
- package/lib/utils/secrets-kv-scope.js +19 -2
- package/lib/utils/secrets-materialize-local.js +134 -0
- package/lib/utils/secrets-path.js +24 -10
- package/lib/utils/secrets-utils.js +2 -2
- package/lib/utils/system-builder-root.js +34 -0
- package/lib/utils/url-declarative-resolve-build.js +6 -1
- package/lib/utils/url-declarative-runtime-base-path.js +32 -0
- package/lib/utils/url-declarative-vdir-inactive-env.js +2 -1
- package/lib/utils/urls-local-registry.js +73 -20
- package/lib/utils/validation-poll-ui.js +81 -0
- package/lib/utils/validation-run-poll.js +29 -5
- package/lib/utils/with-muted-logger.js +53 -0
- package/package.json +1 -1
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/rbac.yaml +10 -10
- package/templates/applications/keycloak/env.template +8 -6
- package/templates/applications/miso-controller/application.yaml +7 -0
- package/templates/applications/miso-controller/env.template +7 -7
- package/templates/applications/miso-controller/rbac.yaml +9 -9
- package/templates/external-system/README.md.hbs +89 -102
- package/.nyc_output/55e9d034-ddab-4579-a706-e02a91d75c91.json +0 -1
- package/.nyc_output/processinfo/55e9d034-ddab-4579-a706-e02a91d75c91.json +0 -1
- package/.nyc_output/processinfo/index.json +0 -1
- package/lib/api/service-users.api.js +0 -150
- package/lib/api/types/service-users.types.js +0 -65
- package/lib/cli/setup-service-user.js +0 -187
- package/lib/commands/service-user.js +0 -429
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-success warnings when public/Traefik health URL was skipped or not verified.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Split from health-check.js for file size limits
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const logger = require('./logger');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* When Traefik URL is set, drop it if the hostname does not resolve; retain full URL for UX.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} traefikUrl
|
|
16
|
+
* @param {boolean} debug
|
|
17
|
+
* @param {Function} isHostnameResolvableFn - (hostname, debug) => Promise<boolean>
|
|
18
|
+
* @returns {Promise<{ traefikUrl: string, skippedPublicHealthUrl: string }>}
|
|
19
|
+
*/
|
|
20
|
+
async function filterTraefikUrlByDns(traefikUrl, debug, isHostnameResolvableFn) {
|
|
21
|
+
if (!traefikUrl) {
|
|
22
|
+
return { traefikUrl: '', skippedPublicHealthUrl: '' };
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const hn = new URL(traefikUrl).hostname;
|
|
26
|
+
const ok = await isHostnameResolvableFn(hn, debug);
|
|
27
|
+
if (!ok) {
|
|
28
|
+
return { traefikUrl: '', skippedPublicHealthUrl: traefikUrl };
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return { traefikUrl: '', skippedPublicHealthUrl: '' };
|
|
32
|
+
}
|
|
33
|
+
return { traefikUrl, skippedPublicHealthUrl: '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Object} p
|
|
38
|
+
* @param {string} [p.skippedPublicHealthUrl]
|
|
39
|
+
* @param {string[]} [p.urlsToTry]
|
|
40
|
+
* @param {number} p.resolvedIndex
|
|
41
|
+
*/
|
|
42
|
+
function logPublicHealthUrlWarningIfNeeded(p) {
|
|
43
|
+
const { skippedPublicHealthUrl, urlsToTry, resolvedIndex } = p;
|
|
44
|
+
if (typeof resolvedIndex !== 'number' || resolvedIndex < 0) return;
|
|
45
|
+
|
|
46
|
+
if (skippedPublicHealthUrl) {
|
|
47
|
+
logger.log(
|
|
48
|
+
chalk.yellow(
|
|
49
|
+
`⚠ Public URL was not verified (DNS): ${skippedPublicHealthUrl}. ` +
|
|
50
|
+
'The application reported healthy via localhost only. Validate DNS names and Traefik routing for this host.'
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(urlsToTry) && urlsToTry.length > 1 && resolvedIndex > 0) {
|
|
56
|
+
const pub = urlsToTry[0];
|
|
57
|
+
logger.log(
|
|
58
|
+
chalk.yellow(
|
|
59
|
+
`⚠ Public URL was not verified: ${pub}. ` +
|
|
60
|
+
'Health checks succeeded via localhost only. Validate Traefik routing, TLS, and DNS.'
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
filterTraefikUrlByDns,
|
|
68
|
+
logPublicHealthUrlWarningIfNeeded
|
|
69
|
+
};
|
|
@@ -73,7 +73,16 @@ function frontDoorPattern(appConfig) {
|
|
|
73
73
|
* @param {Object|null} appConfig
|
|
74
74
|
* @returns {Promise<string|null>}
|
|
75
75
|
*/
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Public app URL (Traefik + frontDoor mount path), without the health path — for CLI summaries.
|
|
78
|
+
*
|
|
79
|
+
* @async
|
|
80
|
+
* @param {string} appName
|
|
81
|
+
* @param {number} healthCheckPort
|
|
82
|
+
* @param {Object|null} appConfig
|
|
83
|
+
* @returns {Promise<string|null>}
|
|
84
|
+
*/
|
|
85
|
+
async function computeTraefikPublicAppUrl(_appName, _healthCheckPort, appConfig) {
|
|
77
86
|
if (!frontDoorEnabled(appConfig)) return null;
|
|
78
87
|
const pattern = frontDoorPattern(appConfig);
|
|
79
88
|
if (!pattern) return null;
|
|
@@ -87,7 +96,7 @@ async function computeTraefikHealthCheckUrl(appName, healthCheckPort, appConfig)
|
|
|
87
96
|
const developerIdRaw = await coreConfig.getDeveloperId();
|
|
88
97
|
const developerIdNum = parseDeveloperIdNum(developerIdRaw);
|
|
89
98
|
|
|
90
|
-
//
|
|
99
|
+
// URLs are resolved for the CLI on the host, not from inside a container.
|
|
91
100
|
const profile = 'local';
|
|
92
101
|
const fd = appConfig.frontDoorRouting;
|
|
93
102
|
const listenPort = Number(appConfig?.port || 3000);
|
|
@@ -105,15 +114,21 @@ async function computeTraefikHealthCheckUrl(appName, healthCheckPort, appConfig)
|
|
|
105
114
|
infraTlsEnabled
|
|
106
115
|
});
|
|
107
116
|
|
|
108
|
-
const healthCheckPath = appConfig?.healthCheck?.path || '/health';
|
|
109
117
|
const mountPath = normalizeFrontDoorPatternForHealth(pattern);
|
|
110
|
-
|
|
118
|
+
return joinUrlPath(publicBase, mountPath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function computeTraefikHealthCheckUrl(appName, healthCheckPort, appConfig) {
|
|
122
|
+
const baseWithFrontDoor = await computeTraefikPublicAppUrl(appName, healthCheckPort, appConfig);
|
|
123
|
+
if (!baseWithFrontDoor) return null;
|
|
124
|
+
const healthCheckPath = appConfig?.healthCheck?.path || '/health';
|
|
111
125
|
return joinUrlPath(baseWithFrontDoor, healthCheckPath);
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
module.exports = {
|
|
115
129
|
joinUrlPath,
|
|
116
130
|
normalizeFrontDoorPatternForHealth,
|
|
131
|
+
computeTraefikPublicAppUrl,
|
|
117
132
|
computeTraefikHealthCheckUrl
|
|
118
133
|
};
|
|
119
134
|
|
|
@@ -12,16 +12,24 @@ const { formatSuccessLine } = require('./cli-test-layout-chalk');
|
|
|
12
12
|
const http = require('http');
|
|
13
13
|
const https = require('https');
|
|
14
14
|
const net = require('net');
|
|
15
|
+
const dns = require('dns');
|
|
15
16
|
const chalk = require('chalk');
|
|
16
17
|
const logger = require('./logger');
|
|
17
18
|
const { execWithDockerEnv } = require('./docker-exec');
|
|
18
19
|
const { computeTraefikHealthCheckUrl } = require('./health-check-url');
|
|
20
|
+
const { computePathActive } = require('./url-declarative-url-flags');
|
|
21
|
+
const { isFrontDoorRoutingEnabledInDoc } = require('./url-declarative-vdir-inactive-env');
|
|
22
|
+
const { waitForDbInit } = require('./health-check-db-init');
|
|
23
|
+
const {
|
|
24
|
+
filterTraefikUrlByDns,
|
|
25
|
+
logPublicHealthUrlWarningIfNeeded
|
|
26
|
+
} = require('./health-check-public-warn');
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Compute the health check URL for an app.
|
|
22
30
|
*
|
|
23
|
-
* - Default (
|
|
24
|
-
* -
|
|
31
|
+
* - Default (path inactive): http://localhost:<port><healthPath> (e.g. Keycloak with KC_HTTP_RELATIVE_PATH=/)
|
|
32
|
+
* - Path active (Traefik on ∧ frontDoorRouting.enabled): localhost probe uses same vdir as the container (e.g. /auth/health/ready)
|
|
25
33
|
*
|
|
26
34
|
* @async
|
|
27
35
|
* @param {string} appName
|
|
@@ -29,117 +37,67 @@ const { computeTraefikHealthCheckUrl } = require('./health-check-url');
|
|
|
29
37
|
* @param {Object|null} appConfig
|
|
30
38
|
* @param {Object} opts
|
|
31
39
|
* @param {Object} [opts.runOptions] - runApp options (may include env + effectiveEnvironmentScopedResources)
|
|
40
|
+
* @param {boolean} [opts.skipTraefikPublicUrl] - Omit Traefik URL (localhost leg in dual-probe flow only).
|
|
32
41
|
* @returns {Promise<string>}
|
|
33
42
|
*/
|
|
34
43
|
async function computeHealthCheckUrl(appName, healthCheckPort, appConfig, _opts = {}) {
|
|
35
|
-
const
|
|
44
|
+
const rawHealthPath = appConfig?.healthCheck?.path || '/health';
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
function computeLocalhostHealthPath() {
|
|
47
|
+
// Plan 124 pathActive: prepend front-door pattern when Traefik on; keep bare /health for miso/dataplane style.
|
|
48
|
+
try {
|
|
49
|
+
const runOptions = _opts && typeof _opts === 'object' && _opts.runOptions ? _opts.runOptions : null;
|
|
50
|
+
const traefikOn = Boolean(runOptions && runOptions.traefikEnabled === true);
|
|
51
|
+
const fd = appConfig && appConfig.frontDoorRouting ? appConfig.frontDoorRouting : null;
|
|
52
|
+
const pattern = fd && typeof fd.pattern === 'string' ? fd.pattern : null;
|
|
53
|
+
const pathActive = computePathActive(traefikOn, isFrontDoorRoutingEnabledInDoc(appConfig || null));
|
|
54
|
+
const shouldMount = pathActive && Boolean(pattern) && rawHealthPath !== '/health';
|
|
55
|
+
if (!shouldMount) return rawHealthPath;
|
|
56
|
+
const { joinUrlPath, normalizeFrontDoorPatternForHealth } = require('./health-check-url');
|
|
57
|
+
const mountPath = normalizeFrontDoorPatternForHealth(pattern);
|
|
58
|
+
return joinUrlPath(mountPath, rawHealthPath);
|
|
59
|
+
} catch {
|
|
60
|
+
return rawHealthPath;
|
|
61
|
+
}
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
|
|
46
|
-
return `http://localhost:${healthCheckPort}${healthCheckPath}`;
|
|
47
|
-
}
|
|
64
|
+
const localhostHealthPath = computeLocalhostHealthPath();
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* @returns {Promise<boolean>} True if container exists
|
|
62
|
-
*/
|
|
63
|
-
async function checkDbInitContainerExists(dbInitContainer) {
|
|
64
|
-
try {
|
|
65
|
-
const { stdout } = await execWithDockerEnv(`docker ps -a --filter "name=${dbInitContainer}" --format "{{.Names}}"`);
|
|
66
|
-
return stdout.trim() === dbInitContainer;
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Gets container exit code
|
|
74
|
-
* @async
|
|
75
|
-
* @function getContainerExitCode
|
|
76
|
-
* @param {string} dbInitContainer - Container name
|
|
77
|
-
* @returns {Promise<string>} Exit code
|
|
78
|
-
*/
|
|
79
|
-
async function getContainerExitCode(dbInitContainer) {
|
|
80
|
-
const { stdout: exitCode } = await execWithDockerEnv(`docker inspect --format='{{.State.ExitCode}}' ${dbInitContainer}`);
|
|
81
|
-
return exitCode.trim();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Handles exited container status
|
|
86
|
-
* @async
|
|
87
|
-
* @function handleExitedContainer
|
|
88
|
-
* @param {string} dbInitContainer - Container name
|
|
89
|
-
* @returns {Promise<boolean>} True if handled (container already exited)
|
|
90
|
-
*/
|
|
91
|
-
async function handleExitedContainer(dbInitContainer) {
|
|
92
|
-
const { stdout: status } = await execWithDockerEnv(`docker inspect --format='{{.State.Status}}' ${dbInitContainer}`);
|
|
93
|
-
if (status.trim() === 'exited') {
|
|
94
|
-
const exitCode = await getContainerExitCode(dbInitContainer);
|
|
95
|
-
if (exitCode === '0') {
|
|
96
|
-
logger.log(formatSuccessLine('Database initialization already completed'));
|
|
97
|
-
} else {
|
|
98
|
-
logger.log(chalk.yellow(`⚠ Database initialization exited with code ${exitCode}`));
|
|
66
|
+
// Local readiness probes localhost; optional Traefik URL is for display / dual-probe (see waitForHealthCheck).
|
|
67
|
+
async function maybeGetTraefikUrl() {
|
|
68
|
+
if (_opts && _opts.skipTraefikPublicUrl) return '';
|
|
69
|
+
const runOptions = (_opts && typeof _opts === 'object') ? _opts.runOptions : null;
|
|
70
|
+
const wantsTraefik =
|
|
71
|
+
Boolean(runOptions) &&
|
|
72
|
+
(runOptions.probeViaTraefik === true || runOptions.traefikEnabled === true);
|
|
73
|
+
if (!wantsTraefik) return '';
|
|
74
|
+
try {
|
|
75
|
+
return await computeTraefikHealthCheckUrl(appName, healthCheckPort, appConfig);
|
|
76
|
+
} catch {
|
|
77
|
+
return '';
|
|
99
78
|
}
|
|
100
|
-
return true;
|
|
101
79
|
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
80
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
* @param {string} dbInitContainer - Container name
|
|
110
|
-
* @param {number} maxAttempts - Maximum attempts
|
|
111
|
-
*/
|
|
112
|
-
async function waitForContainerExit(dbInitContainer, maxAttempts) {
|
|
113
|
-
for (let attempts = 0; attempts < maxAttempts; attempts++) {
|
|
114
|
-
const { stdout: currentStatus } = await execWithDockerEnv(`docker inspect --format='{{.State.Status}}' ${dbInitContainer}`);
|
|
115
|
-
if (currentStatus.trim() === 'exited') {
|
|
116
|
-
const exitCode = await getContainerExitCode(dbInitContainer);
|
|
117
|
-
if (exitCode === '0') {
|
|
118
|
-
logger.log(formatSuccessLine('Database initialization completed'));
|
|
119
|
-
} else {
|
|
120
|
-
logger.log(chalk.yellow(`⚠ Database initialization exited with code ${exitCode}`));
|
|
121
|
-
}
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
125
|
-
}
|
|
81
|
+
const traefikUrl = await maybeGetTraefikUrl();
|
|
82
|
+
if (traefikUrl) return traefikUrl;
|
|
83
|
+
|
|
84
|
+
return `http://localhost:${healthCheckPort}${localhostHealthPath}`;
|
|
126
85
|
}
|
|
127
86
|
|
|
128
|
-
async function
|
|
129
|
-
|
|
87
|
+
async function isHostnameResolvable(hostname, debug) {
|
|
88
|
+
if (!hostname) return false;
|
|
89
|
+
const hn = String(hostname).trim().toLowerCase();
|
|
90
|
+
if (!hn) return false;
|
|
91
|
+
if (hn === 'localhost' || hn === '127.0.0.1' || hn === '::1') return true;
|
|
130
92
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
93
|
+
await dns.promises.lookup(hn);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// ENOTFOUND: caller may log a single post-success warning with the full public health URL.
|
|
97
|
+
if (debug && !(err && err.code === 'ENOTFOUND')) {
|
|
98
|
+
logger.log(chalk.gray(`[DEBUG] DNS lookup failed for ${hostname}: ${err.message}`));
|
|
137
99
|
}
|
|
138
|
-
|
|
139
|
-
logger.log(chalk.blue('Waiting for database initialization to complete...'));
|
|
140
|
-
await waitForContainerExit(dbInitContainer, 30);
|
|
141
|
-
} catch (error) {
|
|
142
|
-
// db-init container might not exist, which is fine
|
|
100
|
+
return false;
|
|
143
101
|
}
|
|
144
102
|
}
|
|
145
103
|
|
|
@@ -411,24 +369,96 @@ async function performHealthCheckAttempt(healthCheckUrl, attempt, maxAttempts, d
|
|
|
411
369
|
return false;
|
|
412
370
|
}
|
|
413
371
|
|
|
372
|
+
async function computePreferredHealthCheckUrls(appName, healthCheckPort, config, runOptions, debug) {
|
|
373
|
+
const localhostUrl = await computeHealthCheckUrl(appName, healthCheckPort, config, {
|
|
374
|
+
runOptions: runOptions && typeof runOptions === 'object' ? runOptions : {},
|
|
375
|
+
skipTraefikPublicUrl: true
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
let traefikUrl = '';
|
|
379
|
+
/** Full Traefik/public health URL when DNS fails — used for one post-success warning. */
|
|
380
|
+
let skippedPublicHealthUrl = '';
|
|
381
|
+
const wantsTraefikFirst = Boolean(
|
|
382
|
+
runOptions && (runOptions.probeViaTraefik === true || runOptions.traefikEnabled === true)
|
|
383
|
+
);
|
|
384
|
+
if (wantsTraefikFirst) {
|
|
385
|
+
try {
|
|
386
|
+
traefikUrl = await computeTraefikHealthCheckUrl(appName, healthCheckPort, config);
|
|
387
|
+
} catch {
|
|
388
|
+
traefikUrl = '';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const filtered = await filterTraefikUrlByDns(traefikUrl, debug, isHostnameResolvable);
|
|
393
|
+
traefikUrl = filtered.traefikUrl;
|
|
394
|
+
skippedPublicHealthUrl = filtered.skippedPublicHealthUrl;
|
|
395
|
+
|
|
396
|
+
const urlsToTry = traefikUrl ? [traefikUrl, localhostUrl] : [localhostUrl];
|
|
397
|
+
if (urlsToTry.length > 1) {
|
|
398
|
+
logger.log(
|
|
399
|
+
chalk.gray(
|
|
400
|
+
`ℹ Health check order: Traefik/DNS (${urlsToTry[0]}), then localhost (${urlsToTry[1]}).`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (debug) {
|
|
405
|
+
logger.log(chalk.gray(`[DEBUG] Health check URLs: ${urlsToTry.join(' | ')}`));
|
|
406
|
+
}
|
|
407
|
+
return { urlsToTry, skippedPublicHealthUrl };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function performHealthCheckAttemptForUrls(urlsToTry, attempt, maxAttempts, debug) {
|
|
411
|
+
for (let i = 0; i < urlsToTry.length; i++) {
|
|
412
|
+
const url = urlsToTry[i];
|
|
413
|
+
const passed = await performHealthCheckAttempt(url, attempt, maxAttempts, debug);
|
|
414
|
+
if (passed) {
|
|
415
|
+
return { ok: true, resolvedIndex: i };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { ok: false, resolvedIndex: -1 };
|
|
419
|
+
}
|
|
420
|
+
|
|
414
421
|
async function waitForHealthCheck(appName, timeout = 90, port = null, config = null, debug = false, runOptions = {}) {
|
|
415
422
|
await waitForDbInit(appName);
|
|
416
423
|
|
|
417
424
|
const healthCheckPort = await determineHealthCheckPort(port, appName, debug);
|
|
418
425
|
const { maxAttempts } = buildHealthCheckConfig(healthCheckPort, config, timeout, debug);
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
426
|
+
const { urlsToTry, skippedPublicHealthUrl } = await computePreferredHealthCheckUrls(
|
|
427
|
+
appName,
|
|
428
|
+
healthCheckPort,
|
|
429
|
+
config,
|
|
430
|
+
runOptions,
|
|
431
|
+
debug
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (skippedPublicHealthUrl && urlsToTry.length === 1) {
|
|
435
|
+
logger.log(
|
|
436
|
+
chalk.gray(
|
|
437
|
+
`ℹ Health check: public URL not used (DNS): ${skippedPublicHealthUrl}. ` +
|
|
438
|
+
`Probing ${urlsToTry[0]} only until the app responds.`
|
|
439
|
+
)
|
|
440
|
+
);
|
|
422
441
|
}
|
|
423
442
|
|
|
424
443
|
for (let attempts = 0; attempts < maxAttempts; attempts++) {
|
|
425
|
-
const
|
|
426
|
-
if (
|
|
444
|
+
const attemptResult = await performHealthCheckAttemptForUrls(urlsToTry, attempts, maxAttempts, debug);
|
|
445
|
+
if (attemptResult.ok) {
|
|
446
|
+
logPublicHealthUrlWarningIfNeeded({
|
|
447
|
+
skippedPublicHealthUrl,
|
|
448
|
+
urlsToTry,
|
|
449
|
+
resolvedIndex: attemptResult.resolvedIndex
|
|
450
|
+
});
|
|
427
451
|
return;
|
|
428
452
|
}
|
|
429
453
|
|
|
430
454
|
if (attempts < maxAttempts - 1) {
|
|
431
|
-
|
|
455
|
+
const probeHint =
|
|
456
|
+
urlsToTry.length > 1
|
|
457
|
+
? `trying ${urlsToTry[0]}, then ${urlsToTry[1]}`
|
|
458
|
+
: (urlsToTry[0] || 'health URL');
|
|
459
|
+
logger.log(
|
|
460
|
+
chalk.yellow(`Waiting for health check… (${attempts + 1}/${maxAttempts}) (${probeHint})`)
|
|
461
|
+
);
|
|
432
462
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
433
463
|
}
|
|
434
464
|
}
|
|
@@ -20,6 +20,8 @@ const CATEGORIES = [
|
|
|
20
20
|
{
|
|
21
21
|
name: 'Infrastructure (Local Development)',
|
|
22
22
|
commands: [
|
|
23
|
+
{ name: 'setup' },
|
|
24
|
+
{ name: 'teardown' },
|
|
23
25
|
{ name: 'up-infra' },
|
|
24
26
|
{ name: 'up-platform' },
|
|
25
27
|
{ name: 'up-miso' },
|
|
@@ -72,7 +74,7 @@ const CATEGORIES = [
|
|
|
72
74
|
{ name: 'app' },
|
|
73
75
|
{ name: 'credential' },
|
|
74
76
|
{ name: 'deployment' },
|
|
75
|
-
{ name: '
|
|
77
|
+
{ name: 'integration-client' }
|
|
76
78
|
]
|
|
77
79
|
},
|
|
78
80
|
{
|
|
@@ -96,6 +98,8 @@ const CATEGORIES = [
|
|
|
96
98
|
{ name: 'delete', term: 'delete <systemKey>' },
|
|
97
99
|
{ name: 'repair', term: 'repair <systemKey>' },
|
|
98
100
|
{ name: 'datasource' },
|
|
101
|
+
{ name: 'dimension' },
|
|
102
|
+
{ name: 'dimension-value' },
|
|
99
103
|
{ name: 'test', term: 'test <app>' },
|
|
100
104
|
{ name: 'test-e2e', term: 'test-e2e <app>' },
|
|
101
105
|
{ name: 'test-integration', term: 'test-integration <app>' }
|
package/lib/utils/image-name.js
CHANGED
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Builds a developer-scoped image name for local Docker builds.
|
|
14
|
-
* Format: "<base>-dev<developerId>".
|
|
15
|
-
* If developerId is missing, non-numeric, or 0 →
|
|
14
|
+
* Format: "<base>-dev<developerId>" when developerId is a positive integer.
|
|
15
|
+
* If developerId is missing, non-numeric, or 0 → returns baseName (manifest image; no dev suffix).
|
|
16
16
|
*
|
|
17
17
|
* @function buildDevImageName
|
|
18
18
|
* @param {string} baseName - Base image name (no registry), e.g., "myapp"
|
|
19
19
|
* @param {(string|number|null|undefined)} developerId - Developer identifier
|
|
20
|
-
* @returns {string} Developer-scoped image name
|
|
20
|
+
* @returns {string} Developer-scoped image name or base name when id is 0 / absent
|
|
21
21
|
*
|
|
22
22
|
* @example
|
|
23
23
|
* buildDevImageName('myapp', 123) // "myapp-dev123"
|
|
24
|
-
* buildDevImageName('myapp', '0') // "myapp
|
|
25
|
-
* buildDevImageName('myapp') // "myapp
|
|
24
|
+
* buildDevImageName('myapp', '0') // "myapp"
|
|
25
|
+
* buildDevImageName('myapp') // "myapp"
|
|
26
26
|
*/
|
|
27
27
|
function buildDevImageName(baseName, developerId) {
|
|
28
28
|
const id =
|
|
@@ -37,13 +37,40 @@ function buildDevImageName(baseName, developerId) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
if (!Number.isFinite(id) || id === 0) {
|
|
40
|
-
return
|
|
40
|
+
return baseName;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
return `${baseName}-dev${id}`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Developer-scoped repository path for run/build resolution (may include registry prefix).
|
|
48
|
+
* For qualified paths (slashes), only the last segment gets `-dev<id>`; id 0 returns path unchanged.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} repositoryPath - Full repository path (e.g. "reg/ns/app" or "app")
|
|
51
|
+
* @param {(string|number|null|undefined)} developerId - Developer id from config
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function buildDevImageRepositoryPath(repositoryPath, developerId) {
|
|
55
|
+
if (!repositoryPath || typeof repositoryPath !== 'string') {
|
|
56
|
+
throw new Error('Repository path is required and must be a string');
|
|
57
|
+
}
|
|
58
|
+
const idNum =
|
|
59
|
+
typeof developerId === 'number' ? developerId : parseInt(String(developerId), 10);
|
|
60
|
+
if (!Number.isFinite(idNum) || idNum === 0) {
|
|
61
|
+
return repositoryPath;
|
|
62
|
+
}
|
|
63
|
+
const idx = repositoryPath.lastIndexOf('/');
|
|
64
|
+
const tail = idx === -1 ? repositoryPath : repositoryPath.slice(idx + 1);
|
|
65
|
+
const scopedTail = buildDevImageName(tail, developerId);
|
|
66
|
+
if (idx === -1) {
|
|
67
|
+
return scopedTail;
|
|
68
|
+
}
|
|
69
|
+
return `${repositoryPath.slice(0, idx)}/${scopedTail}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
module.exports = {
|
|
47
|
-
buildDevImageName
|
|
73
|
+
buildDevImageName,
|
|
74
|
+
buildDevImageRepositoryPath
|
|
48
75
|
};
|
|
49
76
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamped backups under integration/<app>/backup/ (same layout as datasource capability copy).
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Backup before mutating integration JSON/YAML
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True if path exists and is a regular file (not mocked by typical existsSync spies in unit tests).
|
|
16
|
+
* @param {string} filePath
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isRegularFile(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.statSync(filePath).isFile();
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copies filePath into neighbor backup/ with ISO timestamp suffix.
|
|
29
|
+
* @param {string} filePath - Absolute path to file to copy
|
|
30
|
+
* @param {boolean} noBackup - When true, skip and return null
|
|
31
|
+
* @returns {string|null} Destination path or null
|
|
32
|
+
*/
|
|
33
|
+
function writeBackup(filePath, noBackup) {
|
|
34
|
+
if (noBackup) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const dir = path.dirname(filePath);
|
|
38
|
+
const backupDir = path.join(dir, 'backup');
|
|
39
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
40
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
41
|
+
const base = path.basename(filePath);
|
|
42
|
+
const dest = path.join(backupDir, `${base}.${ts}.bak`);
|
|
43
|
+
fs.copyFileSync(filePath, dest);
|
|
44
|
+
return dest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Backs up an existing file once per repair run (dedupes by absolute path).
|
|
49
|
+
* @param {string} filePath - Path to file that will be overwritten
|
|
50
|
+
* @param {{ dryRun?: boolean, noBackup?: boolean, backupPaths?: string[], backedUpFiles?: Set<string> }} ctx
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
function backupIntegrationFile(filePath, ctx) {
|
|
54
|
+
const { dryRun, noBackup, backupPaths, backedUpFiles } = ctx || {};
|
|
55
|
+
if (dryRun || noBackup || !filePath || !isRegularFile(filePath)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const abs = path.resolve(filePath);
|
|
59
|
+
if (backedUpFiles) {
|
|
60
|
+
if (backedUpFiles.has(abs)) return null;
|
|
61
|
+
backedUpFiles.add(abs);
|
|
62
|
+
}
|
|
63
|
+
const dest = writeBackup(filePath, false);
|
|
64
|
+
if (dest && Array.isArray(backupPaths)) {
|
|
65
|
+
backupPaths.push(dest);
|
|
66
|
+
}
|
|
67
|
+
return dest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
writeBackup,
|
|
72
|
+
backupIntegrationFile,
|
|
73
|
+
isRegularFile
|
|
74
|
+
};
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const fs = require('fs');
|
|
12
|
+
const http = require('http');
|
|
12
13
|
const https = require('https');
|
|
13
14
|
const { getAifabrixHome } = require('./paths');
|
|
14
15
|
const { exec } = require('child_process');
|
|
@@ -88,10 +89,36 @@ function fetchLatestRelease() {
|
|
|
88
89
|
* @param {(msg: string) => void} [log] - Optional progress logger
|
|
89
90
|
* @returns {Promise<void>}
|
|
90
91
|
*/
|
|
91
|
-
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} url
|
|
94
|
+
* @param {string} destPath
|
|
95
|
+
* @param {(msg: string) => void} [log]
|
|
96
|
+
* @param {number} [redirectsLeft]
|
|
97
|
+
* @returns {Promise<void>}
|
|
98
|
+
*/
|
|
99
|
+
function downloadToFile(url, destPath, log, redirectsLeft = 8) {
|
|
92
100
|
return new Promise((resolve, reject) => {
|
|
101
|
+
if (redirectsLeft <= 0) {
|
|
102
|
+
reject(new Error('Download redirect loop'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
93
105
|
const file = fs.createWriteStream(destPath, { flags: 'w' });
|
|
94
|
-
const
|
|
106
|
+
const lib = url.startsWith('http:') ? http : https;
|
|
107
|
+
const req = lib.get(url, { headers: { 'User-Agent': 'aifabrix-builder-cli' } }, (res) => {
|
|
108
|
+
const loc = res.headers.location;
|
|
109
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && loc) {
|
|
110
|
+
file.close();
|
|
111
|
+
fs.unlink(destPath, () => {});
|
|
112
|
+
let nextUrl;
|
|
113
|
+
try {
|
|
114
|
+
nextUrl = new URL(loc, url).href;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
reject(new Error(`Invalid redirect Location: ${loc}`));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
downloadToFile(nextUrl, destPath, log, redirectsLeft - 1).then(resolve).catch(reject);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
95
122
|
if (res.statusCode !== 200) {
|
|
96
123
|
file.close();
|
|
97
124
|
fs.unlink(destPath, () => {});
|
|
@@ -111,7 +138,7 @@ function downloadToFile(url, destPath, log) {
|
|
|
111
138
|
req.setTimeout(120000, () => {
|
|
112
139
|
req.destroy(); reject(new Error('Download timeout'));
|
|
113
140
|
});
|
|
114
|
-
if (typeof log === 'function') log('Downloading Mutagen...');
|
|
141
|
+
if (typeof log === 'function' && redirectsLeft === 8) log('Downloading Mutagen...');
|
|
115
142
|
});
|
|
116
143
|
}
|
|
117
144
|
|