@git.zone/tsdeploy 0.2.0 → 0.3.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.tsdeploy.d.ts +32 -1
- package/dist_ts/classes.tsdeploy.js +513 -11
- package/dist_ts/cli.js +59 -2
- package/dist_ts/plugins.d.ts +7 -1
- package/dist_ts/plugins.js +10 -2
- package/dist_ts/types.d.ts +79 -2
- package/package.json +4 -2
- package/readme.md +43 -8
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.tsdeploy.ts +594 -10
- package/ts/cli.ts +58 -1
- package/ts/plugins.ts +13 -1
- package/ts/types.ts +86 -2
package/ts/classes.tsdeploy.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type {
|
|
3
|
+
ITsDeployCommandRunOptions,
|
|
4
|
+
ITsDeployCommandRunResult,
|
|
5
|
+
ITsDeployDeployOptions,
|
|
6
|
+
ITsDeployDeployResult,
|
|
7
|
+
ITsDeployDeploymentSummary,
|
|
3
8
|
ITsDeployDoctorResult,
|
|
4
9
|
ITsDeployLinkOptions,
|
|
5
10
|
ITsDeployPlan,
|
|
@@ -12,14 +17,37 @@ import type {
|
|
|
12
17
|
TTsDeployTarget,
|
|
13
18
|
} from './types.js';
|
|
14
19
|
|
|
20
|
+
type TTypedRequestShape = {
|
|
21
|
+
method: string;
|
|
22
|
+
request: unknown;
|
|
23
|
+
response: unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type TCloudlyRequest = <TRequest extends TTypedRequestShape>(
|
|
27
|
+
originArg: string,
|
|
28
|
+
methodArg: TRequest['method'],
|
|
29
|
+
requestArg: TRequest['request'],
|
|
30
|
+
) => Promise<TRequest['response']>;
|
|
31
|
+
|
|
32
|
+
type TCommandRunner = (optionsArg: ITsDeployCommandRunOptions) => Promise<ITsDeployCommandRunResult>;
|
|
33
|
+
|
|
15
34
|
export interface ITsDeployOptions {
|
|
16
35
|
projectDir?: string;
|
|
17
36
|
profileStorePath?: string;
|
|
18
37
|
ephemeralProfileStore?: boolean;
|
|
38
|
+
env?: Record<string, string | undefined>;
|
|
39
|
+
commandRunner?: TCommandRunner;
|
|
40
|
+
cloudlyRequest?: TCloudlyRequest;
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
const schemaVersion = 1 as const;
|
|
22
44
|
const profileNameRegex = /^[a-zA-Z0-9._-]+$/;
|
|
45
|
+
const defaultPlatform = 'linux/amd64';
|
|
46
|
+
const defaultCloudlyRequestTimeoutMs = 30000;
|
|
47
|
+
const defaultCommandTimeoutMs = 30 * 60 * 1000;
|
|
48
|
+
const dockerCommandTimeoutMs = 60 * 60 * 1000;
|
|
49
|
+
const defaultMaxCommandOutputBytes = 1024 * 1024;
|
|
50
|
+
const defaultVerifyUrlTimeoutMs = 15000;
|
|
23
51
|
|
|
24
52
|
const normalizeProfileName = (profileArg: string): string => {
|
|
25
53
|
const profile = profileArg.trim();
|
|
@@ -53,10 +81,16 @@ export const redactProfile = (profileArg: ITsDeployProfile): ITsDeployPublicProf
|
|
|
53
81
|
export class TsDeploy {
|
|
54
82
|
public readonly projectDir: string;
|
|
55
83
|
private readonly profileStore: plugins.smartconfig.KeyValueStore<ITsDeployUserState>;
|
|
84
|
+
private readonly env: Record<string, string | undefined>;
|
|
85
|
+
private readonly commandRunner: TCommandRunner;
|
|
86
|
+
private readonly cloudlyRequest: TCloudlyRequest;
|
|
56
87
|
|
|
57
88
|
constructor(optionsArg: ITsDeployOptions = {}) {
|
|
58
89
|
this.projectDir = plugins.path.resolve(optionsArg.projectDir ?? process.cwd());
|
|
59
90
|
this.profileStore = this.createProfileStore(optionsArg);
|
|
91
|
+
this.env = optionsArg.env ?? process.env;
|
|
92
|
+
this.commandRunner = optionsArg.commandRunner ?? this.defaultCommandRunner;
|
|
93
|
+
this.cloudlyRequest = optionsArg.cloudlyRequest ?? this.defaultCloudlyRequest;
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
public get projectLinkPath(): string {
|
|
@@ -178,7 +212,7 @@ export class TsDeploy {
|
|
|
178
212
|
warnings.push(`Profile "${activeProfile}" is not configured. Run tsdeploy login --profile ${activeProfile}.`);
|
|
179
213
|
}
|
|
180
214
|
if (profile && !profile.token) {
|
|
181
|
-
warnings.push(`Profile "${profile.name}" has no token.
|
|
215
|
+
warnings.push(`Profile "${profile.name}" has no token. Cloudly deploy requires a token or username/password at deploy time.`);
|
|
182
216
|
}
|
|
183
217
|
return {
|
|
184
218
|
projectDir: this.projectDir,
|
|
@@ -193,11 +227,19 @@ export class TsDeploy {
|
|
|
193
227
|
public async buildPlan(): Promise<ITsDeployPlan> {
|
|
194
228
|
const status = await this.getStatus();
|
|
195
229
|
const actions: ITsDeployPlan['actions'] = [];
|
|
230
|
+
const canDeployCloudly = Boolean(status.projectLink?.serviceId && status.profile?.target === 'cloudly');
|
|
196
231
|
if (status.projectLink && status.profile) {
|
|
197
232
|
actions.push({
|
|
198
233
|
type: 'inspect',
|
|
199
234
|
title: 'Inspect linked deployment',
|
|
200
|
-
description: 'Resolve the linked Cloudly
|
|
235
|
+
description: 'Resolve the linked Cloudly service and registry target before deploying.',
|
|
236
|
+
});
|
|
237
|
+
actions.push({
|
|
238
|
+
type: canDeployCloudly ? 'deploy' : 'noop',
|
|
239
|
+
title: canDeployCloudly ? 'Deploy Cloudly workload image' : 'Deployment link incomplete or unsupported',
|
|
240
|
+
description: canDeployCloudly
|
|
241
|
+
? 'Run project checks, build and push the linked OCI image, and let Cloudly deployOnPush reconcile the service.'
|
|
242
|
+
: 'Cloudly workload deploy requires a serviceId; Onebox deployment mutations are not implemented yet.',
|
|
201
243
|
});
|
|
202
244
|
} else {
|
|
203
245
|
actions.push({
|
|
@@ -206,16 +248,13 @@ export class TsDeploy {
|
|
|
206
248
|
description: 'Create a profile and project link before a deployment plan can inspect remote state.',
|
|
207
249
|
});
|
|
208
250
|
}
|
|
209
|
-
actions.push({
|
|
210
|
-
type: 'noop',
|
|
211
|
-
title: 'Deployment mutation disabled',
|
|
212
|
-
description: 'tsdeploy MVP does not install, update, or upgrade apps until Cloudly/Onebox deployment contracts are verified upstream.',
|
|
213
|
-
});
|
|
214
251
|
return {
|
|
215
252
|
schemaVersion,
|
|
216
253
|
projectDir: this.projectDir,
|
|
217
|
-
mutationAllowed:
|
|
218
|
-
summary:
|
|
254
|
+
mutationAllowed: canDeployCloudly,
|
|
255
|
+
summary: canDeployCloudly
|
|
256
|
+
? 'Cloudly workload deploy is available through registry push and deployOnPush reconciliation.'
|
|
257
|
+
: 'Safe planning only: no remote deployment mutations will be executed.',
|
|
219
258
|
profile: status.activeProfile,
|
|
220
259
|
deploymentId: status.projectLink?.deploymentId,
|
|
221
260
|
serviceId: status.projectLink?.serviceId,
|
|
@@ -240,7 +279,9 @@ export class TsDeploy {
|
|
|
240
279
|
{
|
|
241
280
|
name: 'remote-mutations',
|
|
242
281
|
ok: true,
|
|
243
|
-
message:
|
|
282
|
+
message: status.profile?.target === 'cloudly'
|
|
283
|
+
? 'Cloudly workload deployment is available with deploy-time Cloudly credentials.'
|
|
284
|
+
: 'Only Cloudly workload deployment is implemented currently.',
|
|
244
285
|
},
|
|
245
286
|
];
|
|
246
287
|
return {
|
|
@@ -249,4 +290,547 @@ export class TsDeploy {
|
|
|
249
290
|
warnings: status.warnings,
|
|
250
291
|
};
|
|
251
292
|
}
|
|
293
|
+
|
|
294
|
+
public async deploy(optionsArg: ITsDeployDeployOptions = {}): Promise<ITsDeployDeployResult> {
|
|
295
|
+
const status = await this.getStatus();
|
|
296
|
+
const projectLink = status.projectLink;
|
|
297
|
+
const profile = await this.getProfile(projectLink?.profile ?? status.activeProfile);
|
|
298
|
+
const steps: ITsDeployDeployResult['steps'] = [];
|
|
299
|
+
const warnings: string[] = [];
|
|
300
|
+
const tag = optionsArg.tag || 'latest';
|
|
301
|
+
const platform = optionsArg.platform || defaultPlatform;
|
|
302
|
+
const waitSeconds = optionsArg.waitSeconds ?? 0;
|
|
303
|
+
|
|
304
|
+
if (!projectLink) {
|
|
305
|
+
throw new Error('No .nogit/tsdeploy.json project link found. Run tsdeploy link first.');
|
|
306
|
+
}
|
|
307
|
+
if (!projectLink.serviceId) {
|
|
308
|
+
throw new Error('Project link has no serviceId. Run tsdeploy link --serviceId <id>.');
|
|
309
|
+
}
|
|
310
|
+
if (!profile) {
|
|
311
|
+
throw new Error(`Profile "${projectLink.profile}" is not configured. Run tsdeploy login --profile ${projectLink.profile}.`);
|
|
312
|
+
}
|
|
313
|
+
if (profile.target !== 'cloudly') {
|
|
314
|
+
throw new Error(`Deploy target "${profile.target}" is not implemented. Only Cloudly workload deployment is supported.`);
|
|
315
|
+
}
|
|
316
|
+
const deploySecretValues = this.collectSecretValues(undefined, [
|
|
317
|
+
profile.token,
|
|
318
|
+
optionsArg.token,
|
|
319
|
+
optionsArg.password,
|
|
320
|
+
this.env.TSDEPLOY_TOKEN,
|
|
321
|
+
this.env.TSDEPLOY_CLOUDLY_PASSWORD,
|
|
322
|
+
this.env.CLOUDLY_PASSWORD,
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const localConfig = await this.readProjectSmartconfig();
|
|
326
|
+
const localImage = this.resolveLocalDockerImage(localConfig, tag);
|
|
327
|
+
let image = optionsArg.image || localImage;
|
|
328
|
+
|
|
329
|
+
if (optionsArg.dryRun) {
|
|
330
|
+
if (!image) {
|
|
331
|
+
image = '<resolved-from-cloudly-service-registry-target>';
|
|
332
|
+
warnings.push('Dry run did not contact Cloudly, so the final image URL was not resolved.');
|
|
333
|
+
}
|
|
334
|
+
steps.push({ name: 'cloudly-preflight', status: 'skipped', message: 'Dry run skipped Cloudly service validation.' });
|
|
335
|
+
steps.push({ name: 'checks', status: 'skipped', message: 'Dry run skipped project checks.' });
|
|
336
|
+
steps.push({ name: 'docker-buildx-push', status: 'skipped', message: `Dry run would push ${image}.` });
|
|
337
|
+
return {
|
|
338
|
+
schemaVersion,
|
|
339
|
+
projectDir: this.projectDir,
|
|
340
|
+
profile: profile.name,
|
|
341
|
+
serviceId: projectLink.serviceId,
|
|
342
|
+
deploymentId: projectLink.deploymentId,
|
|
343
|
+
dryRun: true,
|
|
344
|
+
image,
|
|
345
|
+
tag,
|
|
346
|
+
platform,
|
|
347
|
+
pushed: false,
|
|
348
|
+
triggered: false,
|
|
349
|
+
verified: false,
|
|
350
|
+
deployments: [],
|
|
351
|
+
steps,
|
|
352
|
+
warnings,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const identity = await this.resolveCloudlyIdentity(profile, optionsArg);
|
|
357
|
+
steps.push({ name: 'cloudly-auth', status: 'passed', message: 'Cloudly identity resolved.' });
|
|
358
|
+
|
|
359
|
+
const service = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceById>(
|
|
360
|
+
profile.origin,
|
|
361
|
+
'getServiceById',
|
|
362
|
+
{ identity, serviceId: projectLink.serviceId },
|
|
363
|
+
).then(response => response.service);
|
|
364
|
+
if (service.data.deployOnPush === false) {
|
|
365
|
+
throw new Error(`Service "${service.data.name}" has deployOnPush disabled; refusing to push an image that Cloudly will not deploy.`);
|
|
366
|
+
}
|
|
367
|
+
const registryTarget = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceRegistryTarget>(
|
|
368
|
+
profile.origin,
|
|
369
|
+
'getServiceRegistryTarget',
|
|
370
|
+
{ identity, serviceId: projectLink.serviceId, tag },
|
|
371
|
+
).then(response => response.registryTarget);
|
|
372
|
+
if (!registryTarget.imageUrl) {
|
|
373
|
+
throw new Error('Cloudly did not return a registry target image URL for the linked service.');
|
|
374
|
+
}
|
|
375
|
+
if (optionsArg.image && optionsArg.image !== registryTarget.imageUrl) {
|
|
376
|
+
throw new Error(`Requested image "${optionsArg.image}" does not match Cloudly registry target "${registryTarget.imageUrl}".`);
|
|
377
|
+
}
|
|
378
|
+
image = registryTarget.imageUrl;
|
|
379
|
+
if (localImage && localImage !== image) {
|
|
380
|
+
warnings.push(`Local .smartconfig Docker image "${localImage}" differs from Cloudly registry target "${image}". Using Cloudly target.`);
|
|
381
|
+
}
|
|
382
|
+
steps.push({ name: 'cloudly-preflight', status: 'passed', message: `Validated service "${service.data.name}" and registry target.` });
|
|
383
|
+
|
|
384
|
+
if (optionsArg.skipChecks) {
|
|
385
|
+
steps.push({ name: 'checks', status: 'skipped', message: 'Project checks skipped by option.' });
|
|
386
|
+
} else {
|
|
387
|
+
const checkCommand = await this.resolveProjectCheckCommand();
|
|
388
|
+
if (checkCommand) {
|
|
389
|
+
await this.runCheckedCommand({
|
|
390
|
+
command: checkCommand.command,
|
|
391
|
+
args: checkCommand.args,
|
|
392
|
+
cwd: this.projectDir,
|
|
393
|
+
env: this.env,
|
|
394
|
+
timeoutMs: defaultCommandTimeoutMs,
|
|
395
|
+
maxOutputBytes: defaultMaxCommandOutputBytes,
|
|
396
|
+
}, 'checks', steps, deploySecretValues);
|
|
397
|
+
} else {
|
|
398
|
+
steps.push({ name: 'checks', status: 'skipped', message: 'No test or build script found in package.json.' });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let digest: string | undefined;
|
|
403
|
+
if (optionsArg.skipPush) {
|
|
404
|
+
steps.push({ name: 'docker-buildx-push', status: 'skipped', message: 'Docker push skipped by option.' });
|
|
405
|
+
} else {
|
|
406
|
+
const buildEnv = await this.resolveDockerBuildEnv(localConfig);
|
|
407
|
+
const buildArgs = Object.keys(buildEnv.buildArgValues);
|
|
408
|
+
const dockerArgs = [
|
|
409
|
+
'buildx',
|
|
410
|
+
'build',
|
|
411
|
+
...(optionsArg.builder ? ['--builder', optionsArg.builder] : []),
|
|
412
|
+
'--platform',
|
|
413
|
+
platform,
|
|
414
|
+
'--provenance=false',
|
|
415
|
+
'--sbom=false',
|
|
416
|
+
...buildArgs.flatMap(buildArg => ['--build-arg', buildArg]),
|
|
417
|
+
'-t',
|
|
418
|
+
image,
|
|
419
|
+
'--push',
|
|
420
|
+
'.',
|
|
421
|
+
];
|
|
422
|
+
const commandResult = await this.runCheckedCommand({
|
|
423
|
+
command: 'docker',
|
|
424
|
+
args: dockerArgs,
|
|
425
|
+
cwd: this.projectDir,
|
|
426
|
+
env: {
|
|
427
|
+
...this.env,
|
|
428
|
+
...buildEnv.buildArgValues,
|
|
429
|
+
},
|
|
430
|
+
timeoutMs: dockerCommandTimeoutMs,
|
|
431
|
+
maxOutputBytes: defaultMaxCommandOutputBytes,
|
|
432
|
+
}, 'docker-buildx-push', steps, [...deploySecretValues, ...buildEnv.secretValues]);
|
|
433
|
+
digest = this.extractDigest(`${commandResult.stdout}\n${commandResult.stderr}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let deployments = await this.getCloudlyDeployments(profile.origin, identity, projectLink.serviceId);
|
|
437
|
+
if (waitSeconds > 0) {
|
|
438
|
+
deployments = await this.waitForHealthyDeployment(profile.origin, identity, projectLink.serviceId, waitSeconds);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let verifyStatus: number | undefined;
|
|
442
|
+
if (optionsArg.verifyUrl) {
|
|
443
|
+
verifyStatus = await this.verifyUrl(optionsArg.verifyUrl);
|
|
444
|
+
steps.push({
|
|
445
|
+
name: 'verify-url',
|
|
446
|
+
status: verifyStatus >= 200 && verifyStatus < 400 ? 'passed' : 'failed',
|
|
447
|
+
message: `${optionsArg.verifyUrl} returned HTTP ${verifyStatus}.`,
|
|
448
|
+
});
|
|
449
|
+
if (verifyStatus < 200 || verifyStatus >= 400) {
|
|
450
|
+
throw new Error(`Verification URL returned HTTP ${verifyStatus}: ${optionsArg.verifyUrl}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
schemaVersion,
|
|
456
|
+
projectDir: this.projectDir,
|
|
457
|
+
profile: profile.name,
|
|
458
|
+
serviceId: projectLink.serviceId,
|
|
459
|
+
deploymentId: projectLink.deploymentId,
|
|
460
|
+
dryRun: false,
|
|
461
|
+
image,
|
|
462
|
+
tag,
|
|
463
|
+
platform,
|
|
464
|
+
pushed: !optionsArg.skipPush,
|
|
465
|
+
triggered: !optionsArg.skipPush,
|
|
466
|
+
verified: Boolean(optionsArg.verifyUrl && verifyStatus && verifyStatus >= 200 && verifyStatus < 400),
|
|
467
|
+
digest,
|
|
468
|
+
verifyStatus,
|
|
469
|
+
service: {
|
|
470
|
+
serviceId: service.id,
|
|
471
|
+
serviceName: service.data.name,
|
|
472
|
+
deployOnPush: true,
|
|
473
|
+
registryTarget: {
|
|
474
|
+
registryHost: registryTarget.registryHost,
|
|
475
|
+
repository: registryTarget.repository,
|
|
476
|
+
tag: registryTarget.tag,
|
|
477
|
+
imageUrl: registryTarget.imageUrl,
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
deployments,
|
|
481
|
+
steps,
|
|
482
|
+
warnings,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private defaultCommandRunner: TCommandRunner = async (optionsArg) => {
|
|
487
|
+
return await new Promise<ITsDeployCommandRunResult>((resolve, reject) => {
|
|
488
|
+
const timeoutMs = optionsArg.timeoutMs ?? defaultCommandTimeoutMs;
|
|
489
|
+
const maxOutputBytes = optionsArg.maxOutputBytes ?? defaultMaxCommandOutputBytes;
|
|
490
|
+
const child = plugins.childProcess.spawn(optionsArg.command, optionsArg.args, {
|
|
491
|
+
cwd: optionsArg.cwd,
|
|
492
|
+
env: {
|
|
493
|
+
...process.env,
|
|
494
|
+
...optionsArg.env,
|
|
495
|
+
},
|
|
496
|
+
detached: process.platform !== 'win32',
|
|
497
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
498
|
+
});
|
|
499
|
+
const stdoutChunks: Buffer[] = [];
|
|
500
|
+
const stderrChunks: Buffer[] = [];
|
|
501
|
+
let stdoutBytes = 0;
|
|
502
|
+
let stderrBytes = 0;
|
|
503
|
+
let stdoutTruncated = false;
|
|
504
|
+
let stderrTruncated = false;
|
|
505
|
+
let timedOut = false;
|
|
506
|
+
let timeoutRef: NodeJS.Timeout | undefined;
|
|
507
|
+
let killTimeoutRef: NodeJS.Timeout | undefined;
|
|
508
|
+
const appendOutput = (chunksArg: Buffer[], chunkArg: Buffer, bytesArg: number): { bytes: number; truncated: boolean } => {
|
|
509
|
+
const bytes = bytesArg + chunkArg.length;
|
|
510
|
+
const remainingBytes = maxOutputBytes - bytesArg;
|
|
511
|
+
if (remainingBytes > 0) {
|
|
512
|
+
chunksArg.push(chunkArg.subarray(0, remainingBytes));
|
|
513
|
+
}
|
|
514
|
+
return { bytes, truncated: bytes > maxOutputBytes };
|
|
515
|
+
};
|
|
516
|
+
child.stdout?.on('data', chunkArg => {
|
|
517
|
+
const result = appendOutput(stdoutChunks, Buffer.from(chunkArg), stdoutBytes);
|
|
518
|
+
stdoutBytes = result.bytes;
|
|
519
|
+
stdoutTruncated ||= result.truncated;
|
|
520
|
+
});
|
|
521
|
+
child.stderr?.on('data', chunkArg => {
|
|
522
|
+
const result = appendOutput(stderrChunks, Buffer.from(chunkArg), stderrBytes);
|
|
523
|
+
stderrBytes = result.bytes;
|
|
524
|
+
stderrTruncated ||= result.truncated;
|
|
525
|
+
});
|
|
526
|
+
const clearTimers = (clearKillTimerArg = true) => {
|
|
527
|
+
if (timeoutRef) clearTimeout(timeoutRef);
|
|
528
|
+
if (clearKillTimerArg && killTimeoutRef) clearTimeout(killTimeoutRef);
|
|
529
|
+
};
|
|
530
|
+
const killProcess = (signalArg: NodeJS.Signals) => {
|
|
531
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
532
|
+
try {
|
|
533
|
+
process.kill(-child.pid, signalArg);
|
|
534
|
+
return;
|
|
535
|
+
} catch {
|
|
536
|
+
// Fall back to direct child termination below.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
child.kill(signalArg);
|
|
540
|
+
};
|
|
541
|
+
timeoutRef = setTimeout(() => {
|
|
542
|
+
timedOut = true;
|
|
543
|
+
killProcess('SIGTERM');
|
|
544
|
+
killTimeoutRef = setTimeout(() => {
|
|
545
|
+
killProcess('SIGKILL');
|
|
546
|
+
}, 10000);
|
|
547
|
+
killTimeoutRef.unref?.();
|
|
548
|
+
}, timeoutMs);
|
|
549
|
+
timeoutRef.unref?.();
|
|
550
|
+
child.on('error', errorArg => {
|
|
551
|
+
clearTimers(!timedOut);
|
|
552
|
+
reject(errorArg);
|
|
553
|
+
});
|
|
554
|
+
child.on('close', exitCode => {
|
|
555
|
+
clearTimers(!timedOut);
|
|
556
|
+
if (stdoutTruncated) {
|
|
557
|
+
stdoutChunks.push(Buffer.from('\n[tsdeploy: stdout truncated]\n'));
|
|
558
|
+
}
|
|
559
|
+
if (stderrTruncated) {
|
|
560
|
+
stderrChunks.push(Buffer.from('\n[tsdeploy: stderr truncated]\n'));
|
|
561
|
+
}
|
|
562
|
+
if (timedOut) {
|
|
563
|
+
stderrChunks.push(Buffer.from(`\n[tsdeploy: command timed out after ${timeoutMs}ms]\n`));
|
|
564
|
+
}
|
|
565
|
+
resolve({
|
|
566
|
+
command: optionsArg.command,
|
|
567
|
+
args: optionsArg.args,
|
|
568
|
+
exitCode: timedOut ? 124 : exitCode ?? 1,
|
|
569
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
570
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
private defaultCloudlyRequest: TCloudlyRequest = async <TRequest extends TTypedRequestShape>(originArg: string, methodArg: TRequest['method'], requestArg: TRequest['request']): Promise<TRequest['response']> => {
|
|
577
|
+
const controller = new AbortController();
|
|
578
|
+
const timeoutRef = setTimeout(() => controller.abort(), defaultCloudlyRequestTimeoutMs);
|
|
579
|
+
timeoutRef.unref?.();
|
|
580
|
+
try {
|
|
581
|
+
const response = await fetch(`${originArg.replace(/\/+$/, '')}/typedrequest`, {
|
|
582
|
+
method: 'POST',
|
|
583
|
+
headers: {
|
|
584
|
+
'content-type': 'application/json',
|
|
585
|
+
},
|
|
586
|
+
body: JSON.stringify({
|
|
587
|
+
method: methodArg,
|
|
588
|
+
request: requestArg,
|
|
589
|
+
response: {},
|
|
590
|
+
correlation: {
|
|
591
|
+
id: plugins.crypto.randomUUID(),
|
|
592
|
+
phase: 'request',
|
|
593
|
+
},
|
|
594
|
+
}),
|
|
595
|
+
signal: controller.signal,
|
|
596
|
+
});
|
|
597
|
+
if (!response.ok) {
|
|
598
|
+
await response.body?.cancel().catch(() => undefined);
|
|
599
|
+
throw new Error(`Cloudly request "${methodArg}" failed with HTTP ${response.status}.`);
|
|
600
|
+
}
|
|
601
|
+
const payload = await response.json() as {
|
|
602
|
+
response: TRequest['response'];
|
|
603
|
+
error?: { text: string; data?: unknown };
|
|
604
|
+
retry?: { waitForMs: number; reason: string };
|
|
605
|
+
};
|
|
606
|
+
if (payload.error) {
|
|
607
|
+
throw new plugins.typedrequest.TypedResponseError(payload.error.text, payload.error.data);
|
|
608
|
+
}
|
|
609
|
+
if (payload.retry) {
|
|
610
|
+
throw new Error(`Cloudly request "${methodArg}" requested retry after ${payload.retry.waitForMs}ms: ${payload.retry.reason}`);
|
|
611
|
+
}
|
|
612
|
+
return payload.response;
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if ((error as { name?: string }).name === 'AbortError') {
|
|
615
|
+
throw new Error(`Cloudly request "${methodArg}" timed out after ${defaultCloudlyRequestTimeoutMs}ms.`);
|
|
616
|
+
}
|
|
617
|
+
throw error;
|
|
618
|
+
} finally {
|
|
619
|
+
clearTimeout(timeoutRef);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
private async fireCloudlyRequest<TRequest extends TTypedRequestShape>(
|
|
624
|
+
originArg: string,
|
|
625
|
+
methodArg: TRequest['method'],
|
|
626
|
+
requestArg: TRequest['request'],
|
|
627
|
+
): Promise<TRequest['response']> {
|
|
628
|
+
return await this.cloudlyRequest<TRequest>(originArg, methodArg, requestArg);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async resolveCloudlyIdentity(
|
|
632
|
+
profileArg: ITsDeployProfile,
|
|
633
|
+
optionsArg: ITsDeployDeployOptions,
|
|
634
|
+
): Promise<plugins.servezoneInterfaces.data.IIdentity> {
|
|
635
|
+
const username = optionsArg.username || this.env.TSDEPLOY_CLOUDLY_USERNAME || this.env.CLOUDLY_USERNAME;
|
|
636
|
+
const password = optionsArg.password || this.env.TSDEPLOY_CLOUDLY_PASSWORD || this.env.CLOUDLY_PASSWORD;
|
|
637
|
+
if (username && password) {
|
|
638
|
+
const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.admin.IReq_Admin_LoginWithUsernameAndPassword>(
|
|
639
|
+
profileArg.origin,
|
|
640
|
+
'adminLoginWithUsernameAndPassword',
|
|
641
|
+
{ username, password },
|
|
642
|
+
);
|
|
643
|
+
return response.identity;
|
|
644
|
+
}
|
|
645
|
+
const token = optionsArg.token || this.env.TSDEPLOY_TOKEN || profileArg.token;
|
|
646
|
+
if (token) {
|
|
647
|
+
const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
|
|
648
|
+
profileArg.origin,
|
|
649
|
+
'getIdentityByToken',
|
|
650
|
+
{ token },
|
|
651
|
+
);
|
|
652
|
+
return response.identity;
|
|
653
|
+
}
|
|
654
|
+
throw new Error('Cloudly credentials are required for deploy. Provide --token, TSDEPLOY_TOKEN, profile token, or TSDEPLOY_CLOUDLY_USERNAME/TSDEPLOY_CLOUDLY_PASSWORD.');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private async readProjectSmartconfig(): Promise<Record<string, any>> {
|
|
658
|
+
try {
|
|
659
|
+
const raw = await plugins.fs.readFile(plugins.path.join(this.projectDir, '.smartconfig.json'), 'utf8');
|
|
660
|
+
return JSON.parse(raw) as Record<string, any>;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if ((error as { code?: string }).code === 'ENOENT') return {};
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private async readProjectPackageJson(): Promise<Record<string, any>> {
|
|
668
|
+
try {
|
|
669
|
+
const raw = await plugins.fs.readFile(plugins.path.join(this.projectDir, 'package.json'), 'utf8');
|
|
670
|
+
return JSON.parse(raw) as Record<string, any>;
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if ((error as { code?: string }).code === 'ENOENT') return {};
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private resolveLocalDockerImage(configArg: Record<string, any>, tagArg: string): string | undefined {
|
|
678
|
+
const dockerConfig = configArg['@git.zone/tsdocker'];
|
|
679
|
+
const registries = Array.isArray(dockerConfig?.registries) ? dockerConfig.registries : [];
|
|
680
|
+
const registryRepoMap = dockerConfig?.registryRepoMap ?? {};
|
|
681
|
+
const registryHost = registries[0] ?? Object.keys(registryRepoMap)[0];
|
|
682
|
+
const repository = registryHost ? registryRepoMap[registryHost] : undefined;
|
|
683
|
+
if (!registryHost || !repository) return undefined;
|
|
684
|
+
return `${registryHost}/${repository}:${tagArg}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async resolveProjectCheckCommand(): Promise<{ command: string; args: string[] } | undefined> {
|
|
688
|
+
const packageJson = await this.readProjectPackageJson();
|
|
689
|
+
if (packageJson.scripts?.test) {
|
|
690
|
+
return { command: 'pnpm', args: ['test'] };
|
|
691
|
+
}
|
|
692
|
+
if (packageJson.scripts?.build) {
|
|
693
|
+
return { command: 'pnpm', args: ['run', 'build'] };
|
|
694
|
+
}
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async resolveDockerBuildEnv(configArg: Record<string, any>): Promise<{
|
|
699
|
+
buildArgValues: Record<string, string>;
|
|
700
|
+
secretValues: string[];
|
|
701
|
+
}> {
|
|
702
|
+
const dockerConfig = configArg['@git.zone/tsdocker'] ?? {};
|
|
703
|
+
const szciConfig = configArg['@ship.zone/szci'] ?? {};
|
|
704
|
+
const buildArgEnvMap = {
|
|
705
|
+
...(dockerConfig.buildArgEnvMap ?? {}),
|
|
706
|
+
...(szciConfig.dockerBuildargEnvMap ?? {}),
|
|
707
|
+
} as Record<string, string>;
|
|
708
|
+
const buildArgValues: Record<string, string> = {};
|
|
709
|
+
const secretValues: string[] = [];
|
|
710
|
+
for (const [buildArg, envName] of Object.entries(buildArgEnvMap)) {
|
|
711
|
+
const value = this.env[envName];
|
|
712
|
+
if (!value) {
|
|
713
|
+
throw new Error(`Missing required Docker build arg environment value: ${envName}`);
|
|
714
|
+
}
|
|
715
|
+
buildArgValues[buildArg] = value;
|
|
716
|
+
secretValues.push(value);
|
|
717
|
+
}
|
|
718
|
+
return { buildArgValues, secretValues };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private async runCheckedCommand(
|
|
722
|
+
commandArg: ITsDeployCommandRunOptions,
|
|
723
|
+
stepNameArg: string,
|
|
724
|
+
stepsArg: ITsDeployDeployResult['steps'],
|
|
725
|
+
secretValuesArg: string[],
|
|
726
|
+
): Promise<ITsDeployCommandRunResult> {
|
|
727
|
+
const result = await this.commandRunner(commandArg);
|
|
728
|
+
if (result.exitCode !== 0) {
|
|
729
|
+
stepsArg.push({ name: stepNameArg, status: 'failed', message: `${commandArg.command} exited with code ${result.exitCode}.` });
|
|
730
|
+
const output = this.redactText(`${result.stdout}\n${result.stderr}`, this.collectSecretValues(commandArg.env, secretValuesArg));
|
|
731
|
+
throw new Error(`${commandArg.command} failed with exit code ${result.exitCode}:\n${output}`.trim());
|
|
732
|
+
}
|
|
733
|
+
stepsArg.push({ name: stepNameArg, status: 'passed', message: `${commandArg.command} completed successfully.` });
|
|
734
|
+
return result;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private collectSecretValues(
|
|
738
|
+
envArg?: Record<string, string | undefined>,
|
|
739
|
+
additionalValuesArg: Array<string | undefined> = [],
|
|
740
|
+
): string[] {
|
|
741
|
+
const values = new Set<string>();
|
|
742
|
+
const addValue = (valueArg?: string) => {
|
|
743
|
+
if (valueArg && valueArg.length >= 4) values.add(valueArg);
|
|
744
|
+
};
|
|
745
|
+
for (const value of additionalValuesArg) addValue(value);
|
|
746
|
+
if (envArg) {
|
|
747
|
+
for (const [key, value] of Object.entries(envArg)) {
|
|
748
|
+
if (/(token|password|secret|credential|private|auth|key)/i.test(key)) {
|
|
749
|
+
addValue(value);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return [...values];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private redactText(textArg: string, secretValuesArg: string[]): string {
|
|
757
|
+
let result = textArg;
|
|
758
|
+
for (const secret of secretValuesArg.filter(Boolean)) {
|
|
759
|
+
result = result.split(secret).join('[redacted]');
|
|
760
|
+
}
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private extractDigest(outputArg: string): string | undefined {
|
|
765
|
+
const preferredPatterns = [
|
|
766
|
+
/pushing manifest for .+@(sha256:[a-f0-9]{64})/gi,
|
|
767
|
+
/exporting manifest list (sha256:[a-f0-9]{64})/gi,
|
|
768
|
+
/exporting manifest (sha256:[a-f0-9]{64})/gi,
|
|
769
|
+
];
|
|
770
|
+
for (const pattern of preferredPatterns) {
|
|
771
|
+
const matches = [...outputArg.matchAll(pattern)];
|
|
772
|
+
const digest = matches.at(-1)?.[1];
|
|
773
|
+
if (digest) return digest;
|
|
774
|
+
}
|
|
775
|
+
return [...outputArg.matchAll(/sha256:[a-f0-9]{64}/gi)].at(-1)?.[0];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private async getCloudlyDeployments(
|
|
779
|
+
originArg: string,
|
|
780
|
+
identityArg: plugins.servezoneInterfaces.data.IIdentity,
|
|
781
|
+
serviceIdArg: string,
|
|
782
|
+
): Promise<ITsDeployDeploymentSummary[]> {
|
|
783
|
+
const response = await this.fireCloudlyRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeploymentsByService>(
|
|
784
|
+
originArg,
|
|
785
|
+
'getDeploymentsByService',
|
|
786
|
+
{ identity: identityArg, serviceId: serviceIdArg },
|
|
787
|
+
);
|
|
788
|
+
return response.deployments.map(deployment => ({
|
|
789
|
+
deploymentId: deployment.id,
|
|
790
|
+
serviceId: deployment.serviceId,
|
|
791
|
+
serviceName: deployment.serviceName,
|
|
792
|
+
status: deployment.status,
|
|
793
|
+
healthStatus: deployment.healthStatus,
|
|
794
|
+
nodeName: deployment.nodeName,
|
|
795
|
+
version: deployment.version,
|
|
796
|
+
containerId: deployment.containerId,
|
|
797
|
+
deployedAt: deployment.deployedAt,
|
|
798
|
+
updatedAt: deployment.updatedAt,
|
|
799
|
+
}));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private async waitForHealthyDeployment(
|
|
803
|
+
originArg: string,
|
|
804
|
+
identityArg: plugins.servezoneInterfaces.data.IIdentity,
|
|
805
|
+
serviceIdArg: string,
|
|
806
|
+
waitSecondsArg: number,
|
|
807
|
+
): Promise<ITsDeployDeploymentSummary[]> {
|
|
808
|
+
const deadline = Date.now() + waitSecondsArg * 1000;
|
|
809
|
+
let deployments: ITsDeployDeploymentSummary[] = [];
|
|
810
|
+
while (Date.now() <= deadline) {
|
|
811
|
+
deployments = await this.getCloudlyDeployments(originArg, identityArg, serviceIdArg);
|
|
812
|
+
if (deployments.some(deployment => deployment.status === 'running' && deployment.healthStatus === 'healthy')) {
|
|
813
|
+
return deployments;
|
|
814
|
+
}
|
|
815
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
816
|
+
}
|
|
817
|
+
throw new Error(`No healthy running deployment observed for service ${serviceIdArg} within ${waitSecondsArg}s.`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async verifyUrl(urlArg: string): Promise<number> {
|
|
821
|
+
const controller = new AbortController();
|
|
822
|
+
const timeoutRef = setTimeout(() => controller.abort(), defaultVerifyUrlTimeoutMs);
|
|
823
|
+
timeoutRef.unref?.();
|
|
824
|
+
try {
|
|
825
|
+
const response = await fetch(urlArg, { method: 'HEAD', signal: controller.signal });
|
|
826
|
+
return response.status;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
if ((error as { name?: string }).name === 'AbortError') {
|
|
829
|
+
throw new Error(`Verification URL timed out after ${defaultVerifyUrlTimeoutMs}ms: ${urlArg}`);
|
|
830
|
+
}
|
|
831
|
+
throw error;
|
|
832
|
+
} finally {
|
|
833
|
+
clearTimeout(timeoutRef);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
252
836
|
}
|