@aifabrix/builder 2.56.0 → 2.58.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/lib/agent-kit/session-report.js +76 -0
- package/lib/agent-kit/start.js +124 -57
- package/lib/agent-kit/tmux-sessions.js +163 -0
- package/lib/api/environments.api.js +38 -0
- package/lib/api/marketplace-bootstrap.api.js +31 -0
- package/lib/app/push.js +98 -41
- package/lib/build/standard-docker-build.js +13 -11
- package/lib/cli/setup-app.js +16 -5
- package/lib/cli/setup-dev-path-commands.js +49 -2
- package/lib/cli/setup-environment.js +89 -19
- package/lib/commands/agent-kit-help.js +32 -15
- package/lib/commands/agent-kit.js +90 -8
- package/lib/commands/dev-registry.js +155 -0
- package/lib/commands/dev-show-display.js +10 -2
- package/lib/commands/setup-image-refresh.js +26 -0
- package/lib/core/config-attach-extensions.js +4 -1
- package/lib/deployment/installation/azure-infra-stage.js +191 -0
- package/lib/deployment/installation/azure-preflight.js +147 -0
- package/lib/deployment/installation/azure-readiness.js +101 -0
- package/lib/deployment/installation/docker-register.js +112 -0
- package/lib/deployment/installation/environment-auth.js +76 -0
- package/lib/deployment/installation/environment-manifest.js +165 -0
- package/lib/deployment/installation/environment-stage.js +191 -0
- package/lib/deployment/installation/index.js +39 -0
- package/lib/deployment/installation/infra-catalog.js +211 -0
- package/lib/deployment/installation/infra-manifest.js +176 -0
- package/lib/deployment/installation/infra-projection.js +97 -0
- package/lib/deployment/installation/public-ip.js +65 -0
- package/lib/deployment/installation/registry-auth-mode.js +98 -0
- package/lib/deployment/installation/stage-input.js +184 -0
- package/lib/deployment/installation/stage-output.js +53 -0
- package/lib/deployment/installation/workspace-env.js +76 -0
- package/lib/deployment/installation/workspace-files.js +178 -0
- package/lib/deployment/installation/workspace-schema.js +40 -0
- package/lib/deployment/installation/workspace-secrets.js +66 -0
- package/lib/deployment/installation/workspace.js +128 -0
- package/lib/deployment/marketplace-environment.js +305 -0
- package/lib/generator/builders.js +12 -9
- package/lib/generator/helpers.js +55 -56
- package/lib/generator/split-variables.js +11 -5
- package/lib/internal/fs-real-sync.js +29 -0
- package/lib/role-assistant/package-validate.js +4 -0
- package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
- package/lib/role-assistant/test-runner.js +14 -17
- package/lib/schema/application-schema.json +20 -2
- package/lib/schema/infra-parameter.schema.json +7 -0
- package/lib/schema/infra.parameter.yaml +26 -36
- package/lib/schema/infrastructure-schema.json +1531 -397
- package/lib/schema/installation-environment.schema.json +601 -0
- package/lib/utils/app-register-config.js +2 -4
- package/lib/utils/build-resolve-image.js +2 -21
- package/lib/utils/cli-utils.js +8 -1
- package/lib/utils/config-registry-preference.js +106 -0
- package/lib/utils/docker-build.js +53 -19
- package/lib/utils/env-map.js +23 -6
- package/lib/utils/image-tags.js +88 -0
- package/lib/utils/promote-dev-docker-image.js +6 -0
- package/lib/utils/registry-credentials.js +236 -0
- package/lib/utils/resolve-docker-image-ref.js +78 -9
- package/lib/utils/secrets-helpers.js +9 -1
- package/lib/utils/variable-transformer.js +5 -4
- package/lib/validation/required-variables.js +41 -0
- package/lib/validation/validator.js +8 -5
- package/package.json +6 -1
- package/templates/README.md +4 -0
- package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
- package/templates/agent-kit/skills/shared/hosts.md +10 -10
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +1 -1
- package/templates/applications/miso-controller/application.yaml +3 -2
- package/templates/applications/miso-controller/env.template +15 -6
- package/templates/marketplace/createUiDefinition.json +77 -0
- package/templates/marketplace/install.parameters.example.json +9 -0
- package/templates/marketplace/main.json +4087 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installation-level default container registry (`registry` in the Builder config.yaml).
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Registry host validation plus get/set/clear preference helpers
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const yaml = require('js-yaml');
|
|
12
|
+
const fs = require('../internal/fs-real-sync');
|
|
13
|
+
const { getRuntimeConfigFile } = require('../core/config-runtime-paths');
|
|
14
|
+
|
|
15
|
+
const REGISTRY_CONFIG_KEY = 'registry';
|
|
16
|
+
const HOST_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
17
|
+
const REGISTRY_HOST_RE = new RegExp(`^${HOST_LABEL}(?:\\.${HOST_LABEL})*(?::\\d{1,5})?$`);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes and validates a registry host (optionally with a port).
|
|
21
|
+
* Rejects schemes, paths, embedded credentials, query/fragment text and whitespace.
|
|
22
|
+
* @param {string} value - Raw registry value
|
|
23
|
+
* @returns {string} Lower-case host[:port]
|
|
24
|
+
* @throws {Error} When the value is empty or not a plain host
|
|
25
|
+
*/
|
|
26
|
+
function normalizeRegistryHost(value) {
|
|
27
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
28
|
+
const withoutSlash = raw.replace(/\/+$/, '');
|
|
29
|
+
if (!withoutSlash) {
|
|
30
|
+
throw new Error('Registry is required (for example myregistry.azurecr.io)');
|
|
31
|
+
}
|
|
32
|
+
if (/[\s@?#/\\]|:\/\//.test(withoutSlash)) {
|
|
33
|
+
throw new Error(`Invalid registry "${raw}": use a host name such as myregistry.azurecr.io (no scheme, path or credentials)`);
|
|
34
|
+
}
|
|
35
|
+
const host = withoutSlash.toLowerCase();
|
|
36
|
+
const port = host.includes(':') ? Number(host.split(':')[1]) : null;
|
|
37
|
+
if (!REGISTRY_HOST_RE.test(host) || (port !== null && (port < 1 || port > 65535))) {
|
|
38
|
+
throw new Error(`Invalid registry "${raw}": use a host name such as myregistry.azurecr.io`);
|
|
39
|
+
}
|
|
40
|
+
return host;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the installation registry synchronously (used by the shared image resolver).
|
|
45
|
+
* An unreadable or invalid value is treated as unset so image resolution keeps working.
|
|
46
|
+
* @returns {string} Normalized host or '' when unset
|
|
47
|
+
*/
|
|
48
|
+
function readInstallationRegistrySync() {
|
|
49
|
+
try {
|
|
50
|
+
const file = getRuntimeConfigFile();
|
|
51
|
+
if (!fs.existsSync(file)) return '';
|
|
52
|
+
const cfg = yaml.load(fs.readFileSync(file, 'utf8')) || {};
|
|
53
|
+
const value = cfg[REGISTRY_CONFIG_KEY];
|
|
54
|
+
return typeof value === 'string' && value.trim() ? normalizeRegistryHost(value) : '';
|
|
55
|
+
} catch {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Function} getConfigFn - async () => config object
|
|
62
|
+
* @param {Function} saveConfigFn - async (config) => void
|
|
63
|
+
* @returns {{ getRegistry: Function, setRegistry: Function, clearRegistry: Function }}
|
|
64
|
+
*/
|
|
65
|
+
function createRegistryPreferenceFunctions(getConfigFn, saveConfigFn) {
|
|
66
|
+
return {
|
|
67
|
+
/**
|
|
68
|
+
* @returns {Promise<string|null>} Saved registry host or null
|
|
69
|
+
*/
|
|
70
|
+
async getRegistry() {
|
|
71
|
+
const cfg = await getConfigFn();
|
|
72
|
+
const value = cfg[REGISTRY_CONFIG_KEY];
|
|
73
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} registry - Registry host
|
|
78
|
+
* @returns {Promise<string>} Normalized saved value
|
|
79
|
+
*/
|
|
80
|
+
async setRegistry(registry) {
|
|
81
|
+
const host = normalizeRegistryHost(registry);
|
|
82
|
+
const cfg = await getConfigFn();
|
|
83
|
+
cfg[REGISTRY_CONFIG_KEY] = host;
|
|
84
|
+
await saveConfigFn(cfg);
|
|
85
|
+
return host;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Removes the saved registry (idempotent).
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
async clearRegistry() {
|
|
93
|
+
const cfg = await getConfigFn();
|
|
94
|
+
if (!(REGISTRY_CONFIG_KEY in cfg)) return;
|
|
95
|
+
delete cfg[REGISTRY_CONFIG_KEY];
|
|
96
|
+
await saveConfigFn(cfg);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
REGISTRY_CONFIG_KEY,
|
|
103
|
+
normalizeRegistryHost,
|
|
104
|
+
readInstallationRegistrySync,
|
|
105
|
+
createRegistryPreferenceFunctions
|
|
106
|
+
};
|
|
@@ -252,13 +252,44 @@ async function executeBuild(imageName, dockerfilePath, contextPath, tag, options
|
|
|
252
252
|
(options.cache === false || options.noCache === true || options['no-cache'] === true)
|
|
253
253
|
);
|
|
254
254
|
await executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs, noCache);
|
|
255
|
+
await applyTagAliases(imageName, tag, resolveTagAliases(tag, options));
|
|
256
|
+
}
|
|
255
257
|
|
|
256
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Aliases for the built image: explicit `options.tagAliases` from the shared tag plan, else the
|
|
260
|
+
* legacy rule (a single non-floating `options.tag` also gets `latest`).
|
|
261
|
+
* @param {string} tag - Built tag
|
|
262
|
+
* @param {Object} [options] - Build options
|
|
263
|
+
* @returns {string[]}
|
|
264
|
+
*/
|
|
265
|
+
function resolveTagAliases(tag, options) {
|
|
266
|
+
if (options && Array.isArray(options.tagAliases)) {
|
|
267
|
+
return options.tagAliases.filter((alias) => alias !== tag);
|
|
268
|
+
}
|
|
257
269
|
if (options && options.tag && options.tag !== 'latest' && options.tag !== 'main') {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
return ['latest'];
|
|
271
|
+
}
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Tag the built image with every alias (argument arrays; no shell interpolation).
|
|
277
|
+
* @param {string} imageName - Repository
|
|
278
|
+
* @param {string} tag - Built tag
|
|
279
|
+
* @param {string[]} aliases - Additional tags
|
|
280
|
+
* @returns {Promise<void>}
|
|
281
|
+
*/
|
|
282
|
+
async function applyTagAliases(imageName, tag, aliases) {
|
|
283
|
+
if (!aliases.length) return;
|
|
284
|
+
const { execFileWithDockerEnv } = require('./docker-exec');
|
|
285
|
+
const { getDockerExecEnv } = require('./remote-docker-env');
|
|
286
|
+
const env = await getDockerExecEnv();
|
|
287
|
+
for (const alias of aliases) {
|
|
288
|
+
try {
|
|
289
|
+
await execFileWithDockerEnv('docker', ['tag', `${imageName}:${tag}`, `${imageName}:${alias}`], { env });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new Error(`Failed to tag ${imageName}:${alias}: ${error.message}`);
|
|
292
|
+
}
|
|
262
293
|
}
|
|
263
294
|
}
|
|
264
295
|
|
|
@@ -286,26 +317,29 @@ async function executeDockerBuildWithTag(effectiveImageName, imageName, dockerfi
|
|
|
286
317
|
return;
|
|
287
318
|
}
|
|
288
319
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
320
|
+
const { execFileWithDockerEnv } = require('./docker-exec');
|
|
321
|
+
const { getDockerExecEnv } = require('./remote-docker-env');
|
|
322
|
+
const env = await getDockerExecEnv();
|
|
323
|
+
for (const compatTag of [tag, ...resolveTagAliases(tag, options)]) {
|
|
324
|
+
try {
|
|
325
|
+
await execFileWithDockerEnv(
|
|
326
|
+
'docker',
|
|
327
|
+
['tag', `${effectiveImageName}:${compatTag}`, `${imageName}:${compatTag}`],
|
|
328
|
+
{ env }
|
|
329
|
+
);
|
|
330
|
+
logger.log(formatSuccessLine(`Tagged image: ${imageName}:${compatTag}`));
|
|
331
|
+
} catch (err) {
|
|
332
|
+
logger.log(
|
|
333
|
+
formatWarningLine(`Could not create compatibility tag ${imageName}:${compatTag} - ${err.message}`)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
303
336
|
}
|
|
304
337
|
}
|
|
305
338
|
|
|
306
339
|
module.exports = {
|
|
307
340
|
executeDockerBuild,
|
|
308
341
|
executeBuild,
|
|
342
|
+
resolveTagAliases,
|
|
309
343
|
executeDockerBuildWithTag,
|
|
310
344
|
isDockerNotAvailableError
|
|
311
345
|
};
|
package/lib/utils/env-map.js
CHANGED
|
@@ -12,6 +12,8 @@ const fs = require('fs');
|
|
|
12
12
|
const yaml = require('js-yaml');
|
|
13
13
|
const { loadEnvConfig, loadSchemaEnvConfig } = require('./env-config-loader');
|
|
14
14
|
const config = require('../core/config');
|
|
15
|
+
const { buildDevUsernameForFrontDoorHost } = require('./compose-traefik-ingress-base');
|
|
16
|
+
const { resolveRemoteHostForFrontDoor } = require('./remote-host-for-front-door');
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Load base environment variables from env-config.yaml
|
|
@@ -309,19 +311,34 @@ function applyPublicPortAdjustmentsForContext(result, context, devIdNum) {
|
|
|
309
311
|
}
|
|
310
312
|
|
|
311
313
|
/**
|
|
312
|
-
*
|
|
314
|
+
* Add config-derived interpolation values used by env.template.
|
|
313
315
|
* @param {Object} result - Env map (mutated)
|
|
316
|
+
* @param {number} devIdNum - Normalized developer ID
|
|
314
317
|
*/
|
|
315
|
-
async function
|
|
318
|
+
async function applyRuntimeConfigVars(result, devIdNum) {
|
|
319
|
+
let userCfg = {};
|
|
316
320
|
let tlsOn = false;
|
|
317
321
|
try {
|
|
318
|
-
|
|
322
|
+
userCfg = await config.getConfig();
|
|
319
323
|
tlsOn = Boolean(userCfg && userCfg.tlsEnabled === true);
|
|
320
324
|
} catch {
|
|
321
|
-
|
|
325
|
+
userCfg = {};
|
|
322
326
|
}
|
|
323
327
|
result.TLS_ENABLED = tlsOn ? 'true' : 'false';
|
|
324
328
|
result.HTTP_ENABLED = tlsOn ? 'false' : 'true';
|
|
329
|
+
const devUsername = buildDevUsernameForFrontDoorHost(devIdNum);
|
|
330
|
+
const remoteServerHost = resolveRemoteHostForFrontDoor({
|
|
331
|
+
remoteServer: userCfg && userCfg['remote-server']
|
|
332
|
+
});
|
|
333
|
+
const remoteHost = remoteServerHost ? remoteServerHost.split('.')[0] : '';
|
|
334
|
+
const devRootDomain = resolveRemoteHostForFrontDoor({
|
|
335
|
+
developerRootDomain: userCfg && userCfg['developer-root-domain']
|
|
336
|
+
});
|
|
337
|
+
if (devUsername || remoteHost) {
|
|
338
|
+
result.DEV_USERNAME = devUsername;
|
|
339
|
+
result.REMOTE_HOST = remoteHost;
|
|
340
|
+
}
|
|
341
|
+
if (devRootDomain) result.DEV_ROOT_DOMAIN = devRootDomain;
|
|
325
342
|
}
|
|
326
343
|
|
|
327
344
|
/**
|
|
@@ -333,6 +350,7 @@ async function applyTlsHttpManifestFlags(result) {
|
|
|
333
350
|
* - Applies developer-id adjustment to port variables for local context
|
|
334
351
|
* - Calculates *_PUBLIC_PORT for both local and docker context (basePort + developer-id * 100)
|
|
335
352
|
* - Adds TLS_ENABLED and HTTP_ENABLED ("true" / "false"); HTTP_ENABLED = !TLS_ENABLED for manifest ${...} substitution
|
|
353
|
+
* - Adds DEV_USERNAME, REMOTE_HOST, and DEV_ROOT_DOMAIN from Builder configuration
|
|
336
354
|
* @async
|
|
337
355
|
* @function buildEnvVarMap
|
|
338
356
|
* @param {'docker'|'local'} context - Environment context
|
|
@@ -358,7 +376,7 @@ async function buildEnvVarMap(context, osModule = null, developerId = null, opti
|
|
|
358
376
|
applyLocalPortAdjustment(result, devIdNum);
|
|
359
377
|
}
|
|
360
378
|
applyPublicPortAdjustmentsForContext(result, context, devIdNum);
|
|
361
|
-
await
|
|
379
|
+
await applyRuntimeConfigVars(result, devIdNum);
|
|
362
380
|
return result;
|
|
363
381
|
}
|
|
364
382
|
|
|
@@ -367,4 +385,3 @@ module.exports = {
|
|
|
367
385
|
getDeveloperIdNumber,
|
|
368
386
|
resolvePortInEnvValues
|
|
369
387
|
};
|
|
370
|
-
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image tag lists for build and push: comma-separated tags, exact `{version}` expansion from the
|
|
3
|
+
* application's `app.version`, Docker tag validation, and order-preserving de-duplication.
|
|
4
|
+
* All validation happens before any Docker call.
|
|
5
|
+
*
|
|
6
|
+
* @fileoverview Shared image tag resolution
|
|
7
|
+
* @author AI Fabrix Team
|
|
8
|
+
* @version 1.0.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { assertSafeTag } = require('./resolve-docker-image-ref');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TAG = 'latest';
|
|
16
|
+
const VERSION_PLACEHOLDER = '{version}';
|
|
17
|
+
/** Tags that are floating aliases themselves (a single explicit one gets no extra `latest`). */
|
|
18
|
+
const FLOATING_TAGS = Object.freeze(['latest', 'main']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} [appConfig] - Application manifest (possibly wrapped in `variables`)
|
|
22
|
+
* @returns {string} Trimmed `app.version` or ''
|
|
23
|
+
*/
|
|
24
|
+
function readAppVersion(appConfig) {
|
|
25
|
+
const cfg = appConfig?.variables?.app ? appConfig.variables : appConfig;
|
|
26
|
+
const raw = cfg?.app?.version;
|
|
27
|
+
return raw === undefined || raw === null ? '' : String(raw).trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expandTag(entry, version) {
|
|
31
|
+
if (entry === VERSION_PLACEHOLDER) {
|
|
32
|
+
if (!version) {
|
|
33
|
+
throw new Error('Tag {version} requires a non-empty app.version in the application manifest');
|
|
34
|
+
}
|
|
35
|
+
return version;
|
|
36
|
+
}
|
|
37
|
+
if (entry.includes('{') || entry.includes('}')) {
|
|
38
|
+
throw new Error(`Unsupported tag placeholder "${entry}"; only the exact token {version} is supported`);
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a tag option into a validated, de-duplicated list (first occurrence wins).
|
|
45
|
+
* @param {string|undefined} tagInput - `--tag` value (omitted means `latest`)
|
|
46
|
+
* @param {string} appVersion - Application `app.version`
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
* @throws {Error} On blank entries, unsupported placeholders, missing version or invalid tags
|
|
49
|
+
*/
|
|
50
|
+
function resolveImageTags(tagInput, appVersion) {
|
|
51
|
+
if (tagInput === undefined || tagInput === null || String(tagInput).trim() === '') {
|
|
52
|
+
return [DEFAULT_TAG];
|
|
53
|
+
}
|
|
54
|
+
const entries = String(tagInput).split(',').map((entry) => entry.trim());
|
|
55
|
+
if (entries.some((entry) => entry === '')) {
|
|
56
|
+
throw new Error(`Invalid tag list "${tagInput}": remove empty entries between commas`);
|
|
57
|
+
}
|
|
58
|
+
const resolved = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const tag = assertSafeTag(expandTag(entry, String(appVersion || '').trim()));
|
|
61
|
+
if (!resolved.includes(tag)) resolved.push(tag);
|
|
62
|
+
}
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build plan: the first tag is the Docker build target; the rest are aliases of the same image.
|
|
68
|
+
* A single explicit non-floating tag keeps its compatibility `latest` alias.
|
|
69
|
+
* @param {string|undefined} tagInput - `--tag` value
|
|
70
|
+
* @param {string} appVersion - Application `app.version`
|
|
71
|
+
* @returns {{ primary: string, aliases: string[], tags: string[] }}
|
|
72
|
+
*/
|
|
73
|
+
function resolveBuildTagPlan(tagInput, appVersion) {
|
|
74
|
+
const tags = resolveImageTags(tagInput, appVersion);
|
|
75
|
+
const explicitSingle = tags.length === 1 && tagInput !== undefined && tagInput !== null && String(tagInput).trim() !== '';
|
|
76
|
+
if (explicitSingle && !FLOATING_TAGS.includes(tags[0])) {
|
|
77
|
+
tags.push(DEFAULT_TAG);
|
|
78
|
+
}
|
|
79
|
+
return { primary: tags[0], aliases: tags.slice(1), tags };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
DEFAULT_TAG,
|
|
84
|
+
VERSION_PLACEHOLDER,
|
|
85
|
+
readAppVersion,
|
|
86
|
+
resolveImageTags,
|
|
87
|
+
resolveBuildTagPlan
|
|
88
|
+
};
|
|
@@ -88,6 +88,12 @@ function resolvePromoteVersionTag(options = {}) {
|
|
|
88
88
|
* @returns {string[]}
|
|
89
89
|
*/
|
|
90
90
|
function resolvePromoteBaseTags(options = {}) {
|
|
91
|
+
if (options.tagPlan && Array.isArray(options.tagPlan.tags) && options.tagPlan.tags.length > 0) {
|
|
92
|
+
// Shared build tag plan (comma list / {version}); every tag becomes a base alias.
|
|
93
|
+
const tags = [...options.tagPlan.tags];
|
|
94
|
+
if (!tags.includes(PROMOTE_FLOATING_TAG)) tags.push(PROMOTE_FLOATING_TAG);
|
|
95
|
+
return tags;
|
|
96
|
+
}
|
|
91
97
|
const versionTag = resolvePromoteVersionTag(options);
|
|
92
98
|
const tags = [versionTag];
|
|
93
99
|
if (versionTag !== PROMOTE_FLOATING_TAG) {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-scoped container registry credentials in the primary user secrets file.
|
|
3
|
+
*
|
|
4
|
+
* Keys are exactly `<registry>-username` and `<registry>-password` in the user's
|
|
5
|
+
* `secrets.local.yaml` (never the shared store). Values are encrypted through the existing
|
|
6
|
+
* local-secret writer and reach Docker only via `docker login --password-stdin`.
|
|
7
|
+
*
|
|
8
|
+
* @fileoverview Registry credential storage, lookup, removal, and per-command Docker login
|
|
9
|
+
* @author AI Fabrix Team
|
|
10
|
+
* @version 1.0.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const yaml = require('js-yaml');
|
|
17
|
+
const config = require('../core/config');
|
|
18
|
+
const pathsUtil = require('./paths');
|
|
19
|
+
const { loadExistingSecrets, mergeSecretsIntoFile } = require('./secrets-generator');
|
|
20
|
+
const { saveLocalSecret } = require('./local-secrets');
|
|
21
|
+
const { decryptSecret, isEncrypted } = require('./secrets-encryption');
|
|
22
|
+
const { normalizeRegistryHost } = require('./config-registry-preference');
|
|
23
|
+
|
|
24
|
+
/** Hosts already authenticated during this CLI process. */
|
|
25
|
+
const authenticatedHosts = new Set();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} registry - Registry host
|
|
29
|
+
* @returns {{ host: string, usernameKey: string, passwordKey: string }}
|
|
30
|
+
*/
|
|
31
|
+
function registryCredentialKeys(registry) {
|
|
32
|
+
const host = normalizeRegistryHost(registry);
|
|
33
|
+
return { host, usernameKey: `${host}-username`, passwordKey: `${host}-password` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasValue(value) {
|
|
37
|
+
return typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRawPair(keys) {
|
|
41
|
+
const file = pathsUtil.getPrimaryUserSecretsLocalPath();
|
|
42
|
+
const raw = loadExistingSecrets(file);
|
|
43
|
+
return { file, username: raw[keys.usernameKey], password: raw[keys.passwordKey] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function partialPairError(keys) {
|
|
47
|
+
return new Error(
|
|
48
|
+
`Registry credentials for ${keys.host} are incomplete: both ${keys.usernameKey} and ${keys.passwordKey} ` +
|
|
49
|
+
`must be set. Run "aifabrix dev set-registry ${keys.host} --username <username> --password-stdin" ` +
|
|
50
|
+
`or "aifabrix dev set-registry ${keys.host} --clear-credentials".`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Credential presence for a registry without decrypting anything.
|
|
56
|
+
* @param {string} registry - Registry host
|
|
57
|
+
* @returns {'complete'|'absent'|'partial'}
|
|
58
|
+
*/
|
|
59
|
+
function getRegistryCredentialStatus(registry) {
|
|
60
|
+
const keys = registryCredentialKeys(registry);
|
|
61
|
+
const pair = readRawPair(keys);
|
|
62
|
+
const count = [pair.username, pair.password].filter(hasValue).length;
|
|
63
|
+
if (count === 2) return 'complete';
|
|
64
|
+
return count === 0 ? 'absent' : 'partial';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function decryptStoredValue(value, label) {
|
|
68
|
+
const text = String(value);
|
|
69
|
+
if (!isEncrypted(text)) return text;
|
|
70
|
+
const key = await config.getSecretsEncryptionKey();
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`Cannot decrypt the stored ${label}: the local secrets encryption key is not configured`);
|
|
73
|
+
}
|
|
74
|
+
return decryptSecret(text, key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Loads a complete credential pair for the registry.
|
|
79
|
+
* @param {string} registry - Registry host
|
|
80
|
+
* @returns {Promise<{ username: string, password: string }|null>} null when no pair is stored
|
|
81
|
+
* @throws {Error} When only one of the two keys is stored
|
|
82
|
+
*/
|
|
83
|
+
async function loadRegistryCredentials(registry) {
|
|
84
|
+
const keys = registryCredentialKeys(registry);
|
|
85
|
+
const pair = readRawPair(keys);
|
|
86
|
+
const present = [pair.username, pair.password].filter(hasValue).length;
|
|
87
|
+
if (present === 0) return null;
|
|
88
|
+
if (present === 1) throw partialPairError(keys);
|
|
89
|
+
return {
|
|
90
|
+
username: await decryptStoredValue(pair.username, `${keys.host} username`),
|
|
91
|
+
password: await decryptStoredValue(pair.password, `${keys.host} password`)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removeKeysFromFile(file, keyNames) {
|
|
96
|
+
if (!fs.existsSync(file)) return false;
|
|
97
|
+
const data = loadExistingSecrets(file);
|
|
98
|
+
const present = keyNames.filter((key) => Object.prototype.hasOwnProperty.call(data, key));
|
|
99
|
+
if (present.length === 0) return false;
|
|
100
|
+
for (const key of present) delete data[key];
|
|
101
|
+
const { assertWritableSecretsPathForTests } = require('./aifabrix-test-runtime-guard');
|
|
102
|
+
assertWritableSecretsPathForTests(file);
|
|
103
|
+
fs.writeFileSync(file, yaml.dump(data, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false }), { mode: 0o600 });
|
|
104
|
+
// `mode` applies only on creation; keep an existing file owner-only too.
|
|
105
|
+
fs.chmodSync(file, 0o600);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function restorePair(keys, snapshot) {
|
|
110
|
+
const restore = {};
|
|
111
|
+
const remove = [];
|
|
112
|
+
for (const [key, value] of [[keys.usernameKey, snapshot.username], [keys.passwordKey, snapshot.password]]) {
|
|
113
|
+
if (value === undefined) remove.push(key);
|
|
114
|
+
else restore[key] = value;
|
|
115
|
+
}
|
|
116
|
+
removeKeysFromFile(snapshot.file, remove);
|
|
117
|
+
mergeSecretsIntoFile(snapshot.file, restore);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Saves a complete pair. On any write failure the previous entries are restored.
|
|
122
|
+
* @param {string} registry - Registry host
|
|
123
|
+
* @param {string} username - Registry username
|
|
124
|
+
* @param {string} password - Registry password or token
|
|
125
|
+
* @returns {Promise<{ host: string, rollback: Function }>} rollback restores the prior entries
|
|
126
|
+
*/
|
|
127
|
+
async function saveRegistryCredentials(registry, username, password) {
|
|
128
|
+
const keys = registryCredentialKeys(registry);
|
|
129
|
+
if (!hasValue(username) || !hasValue(password)) {
|
|
130
|
+
throw new Error('Registry username and password must both be non-empty');
|
|
131
|
+
}
|
|
132
|
+
const snapshot = readRawPair(keys);
|
|
133
|
+
await config.ensureSecretsEncryptionKey();
|
|
134
|
+
try {
|
|
135
|
+
await saveLocalSecret(keys.usernameKey, String(username));
|
|
136
|
+
await saveLocalSecret(keys.passwordKey, String(password));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
restorePair(keys, snapshot);
|
|
139
|
+
throw new Error(`Could not save registry credentials for ${keys.host}; previous values were restored (${error.message})`);
|
|
140
|
+
}
|
|
141
|
+
authenticatedHosts.delete(keys.host);
|
|
142
|
+
return { host: keys.host, rollback: () => restorePair(keys, snapshot) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Removes both credential keys for the registry (idempotent).
|
|
147
|
+
* @param {string} registry - Registry host
|
|
148
|
+
* @returns {boolean} true when at least one key was removed
|
|
149
|
+
*/
|
|
150
|
+
function removeRegistryCredentials(registry) {
|
|
151
|
+
const keys = registryCredentialKeys(registry);
|
|
152
|
+
authenticatedHosts.delete(keys.host);
|
|
153
|
+
return removeKeysFromFile(pathsUtil.getPrimaryUserSecretsLocalPath(), [keys.usernameKey, keys.passwordKey]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Authenticates Docker to the registry once per process: stored pair first, then the optional
|
|
158
|
+
* fallback (for example Azure CLI for ACR). The password is sent only on stdin.
|
|
159
|
+
* @param {string} registry - Registry host
|
|
160
|
+
* @param {Object} [options] - Options
|
|
161
|
+
* @param {Function} [options.fallback] - async (host) => void when no pair is stored
|
|
162
|
+
* @param {Function} [options.login] - async (host, username, password) => void (tests)
|
|
163
|
+
* @returns {Promise<'cached'|'credentials'|'fallback'|'none'>}
|
|
164
|
+
*/
|
|
165
|
+
async function ensureRegistryLogin(registry, options = {}) {
|
|
166
|
+
const host = normalizeRegistryHost(registry);
|
|
167
|
+
if (authenticatedHosts.has(host)) return 'cached';
|
|
168
|
+
const credentials = await loadRegistryCredentials(host);
|
|
169
|
+
if (credentials) {
|
|
170
|
+
const login = options.login || require('../deployment/push').authenticateExternalRegistry;
|
|
171
|
+
await login(host, credentials.username, credentials.password);
|
|
172
|
+
authenticatedHosts.add(host);
|
|
173
|
+
return 'credentials';
|
|
174
|
+
}
|
|
175
|
+
if (typeof options.fallback === 'function') {
|
|
176
|
+
await options.fallback(host);
|
|
177
|
+
authenticatedHosts.add(host);
|
|
178
|
+
return 'fallback';
|
|
179
|
+
}
|
|
180
|
+
return 'none';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Registry host of an image reference, or '' for Docker Hub style refs.
|
|
185
|
+
* @param {string} imageRef - Image reference (`host/repo:tag`, `repo:tag`, ...)
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function registryHostFromImageRef(imageRef) {
|
|
189
|
+
const ref = String(imageRef || '').trim();
|
|
190
|
+
const slash = ref.indexOf('/');
|
|
191
|
+
if (slash <= 0) return '';
|
|
192
|
+
const first = ref.slice(0, slash);
|
|
193
|
+
if (!(first.includes('.') || first.includes(':') || first === 'localhost')) return '';
|
|
194
|
+
try {
|
|
195
|
+
return normalizeRegistryHost(first);
|
|
196
|
+
} catch {
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Logs in once per distinct registry host of the given image refs when a stored pair exists.
|
|
203
|
+
* Hosts without stored credentials are pulled anonymously (or with the user's own Docker login).
|
|
204
|
+
* @param {string[]} imageRefs - Image references
|
|
205
|
+
* @returns {Promise<string[]>} Hosts logged in with stored credentials
|
|
206
|
+
*/
|
|
207
|
+
async function ensureLoginForImageRefs(imageRefs) {
|
|
208
|
+
const hosts = [...new Set((imageRefs || []).map(registryHostFromImageRef).filter(Boolean))];
|
|
209
|
+
const used = [];
|
|
210
|
+
for (const host of hosts) {
|
|
211
|
+
const outcome = await ensureRegistryLogin(host);
|
|
212
|
+
if (outcome === 'credentials') used.push(host);
|
|
213
|
+
}
|
|
214
|
+
return used;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Forgets cached logins (used before re-authentication and in tests).
|
|
219
|
+
* @param {string} [registry] - Host to forget; all when omitted
|
|
220
|
+
*/
|
|
221
|
+
function resetRegistryLoginCache(registry) {
|
|
222
|
+
if (registry) authenticatedHosts.delete(normalizeRegistryHost(registry));
|
|
223
|
+
else authenticatedHosts.clear();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
registryCredentialKeys,
|
|
228
|
+
getRegistryCredentialStatus,
|
|
229
|
+
loadRegistryCredentials,
|
|
230
|
+
saveRegistryCredentials,
|
|
231
|
+
removeRegistryCredentials,
|
|
232
|
+
ensureRegistryLogin,
|
|
233
|
+
ensureLoginForImageRefs,
|
|
234
|
+
registryHostFromImageRef,
|
|
235
|
+
resetRegistryLoginCache
|
|
236
|
+
};
|