@git.zone/cli 6.0.1 → 6.1.1
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/helpers.workflow.d.ts +2 -0
- package/dist_ts/helpers.workflow.js +96 -25
- package/dist_ts/mod_config/index.js +176 -88
- package/dist_ts/mod_release/classes.releasejournal.d.ts +60 -10
- package/dist_ts/mod_release/classes.releasejournal.js +513 -21
- package/dist_ts/mod_release/helpers.releasepublication.d.ts +5 -3
- package/dist_ts/mod_release/helpers.releasepublication.js +390 -13
- package/dist_ts/mod_release/helpers.tsdockerprotocol.d.ts +60 -0
- package/dist_ts/mod_release/helpers.tsdockerprotocol.js +442 -0
- package/dist_ts/mod_release/index.d.ts +1 -1
- package/dist_ts/mod_release/index.js +101 -19
- package/dist_ts/plugins.d.ts +5 -1
- package/dist_ts/plugins.js +5 -2
- package/package.json +2 -1
- package/readme.hints.md +43 -17
- package/readme.md +83 -75
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.workflow.ts +191 -49
- package/ts/mod_config/index.ts +320 -146
- package/ts/mod_release/classes.releasejournal.ts +939 -70
- package/ts/mod_release/helpers.releasepublication.ts +786 -49
- package/ts/mod_release/helpers.tsdockerprotocol.ts +806 -0
- package/ts/mod_release/index.ts +231 -74
- package/ts/plugins.ts +20 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import * as plugins from "./mod.plugins.js";
|
|
2
|
+
import { getProjectTsdockerBinPath } from "../helpers.tsdocker.js";
|
|
3
|
+
import { supportsTsdockerMinimumVersion } from "../helpers.workflow.js";
|
|
4
|
+
import type {
|
|
5
|
+
IReleaseCapabilitiesResult,
|
|
6
|
+
IReleaseCleanupResult,
|
|
7
|
+
IReleaseDestinationEvidence,
|
|
8
|
+
IReleaseProbeResult,
|
|
9
|
+
IReleasePromotionContext,
|
|
10
|
+
IReleasePromotionResult,
|
|
11
|
+
IReleaseQualificationRequest,
|
|
12
|
+
IReleaseQualificationResult,
|
|
13
|
+
IReleaseValidationResult,
|
|
14
|
+
TReleasePromotionEvidence,
|
|
15
|
+
TReleaseProtocolCommand,
|
|
16
|
+
} from "./mod.plugins.js";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
IReleaseCapabilitiesResult,
|
|
20
|
+
IReleaseCleanupResult,
|
|
21
|
+
IReleaseDestinationEvidence,
|
|
22
|
+
IReleaseProbeResult,
|
|
23
|
+
IReleasePromotionContext,
|
|
24
|
+
IReleasePromotionResult,
|
|
25
|
+
IReleaseQualificationRequest,
|
|
26
|
+
IReleaseQualificationResult,
|
|
27
|
+
IReleaseValidationResult,
|
|
28
|
+
TReleasePromotionEvidence,
|
|
29
|
+
TReleaseProtocolCommand,
|
|
30
|
+
} from "./mod.plugins.js";
|
|
31
|
+
|
|
32
|
+
export const assertReleaseCleanupResult =
|
|
33
|
+
plugins.tsdockerReleaseProtocol.assertReleaseCleanupResult;
|
|
34
|
+
export const assertReleasePromotionEvidence =
|
|
35
|
+
plugins.tsdockerReleaseProtocol.assertReleasePromotionEvidence;
|
|
36
|
+
export const assertReleaseProbeResult =
|
|
37
|
+
plugins.tsdockerReleaseProtocol.assertReleaseProbeResult;
|
|
38
|
+
export const assertReleasePromotionResult =
|
|
39
|
+
plugins.tsdockerReleaseProtocol.assertReleasePromotionResult;
|
|
40
|
+
export const assertReleaseQualificationRequest =
|
|
41
|
+
plugins.tsdockerReleaseProtocol.assertReleaseQualificationRequest;
|
|
42
|
+
export const assertReleaseQualificationResult =
|
|
43
|
+
plugins.tsdockerReleaseProtocol.assertReleaseQualificationResult;
|
|
44
|
+
export const canonicalReleaseJson =
|
|
45
|
+
plugins.tsdockerReleaseProtocol.canonicalReleaseJson;
|
|
46
|
+
export const canonicalReleaseJsonDocument =
|
|
47
|
+
plugins.tsdockerReleaseProtocol.canonicalReleaseJsonDocument;
|
|
48
|
+
export const classifyReleasePromotionEvidence =
|
|
49
|
+
plugins.tsdockerReleaseProtocol.classifyReleasePromotionEvidence;
|
|
50
|
+
export const createReleaseProjectId =
|
|
51
|
+
plugins.tsdockerReleaseProtocol.createReleaseProjectId;
|
|
52
|
+
export const createReleasePromotionId =
|
|
53
|
+
plugins.tsdockerReleaseProtocol.createReleasePromotionId;
|
|
54
|
+
export const createReleaseQualificationRequest =
|
|
55
|
+
plugins.tsdockerReleaseProtocol.createReleaseQualificationRequest;
|
|
56
|
+
export const getReleasePromotionContexts =
|
|
57
|
+
plugins.tsdockerReleaseProtocol.getReleasePromotionContexts;
|
|
58
|
+
|
|
59
|
+
const responseByteLimit = 1024 * 1024;
|
|
60
|
+
const requestByteLimit = 64 * 1024;
|
|
61
|
+
const packageJsonByteLimit = 64 * 1024;
|
|
62
|
+
const requestOwnerIdPattern = /^[a-f0-9]{32}$/;
|
|
63
|
+
const requestFilePattern =
|
|
64
|
+
/^\.tsdocker-request\.([a-f0-9]{32})\.(release-(?:validate|qualify|probe|promote|cleanup))\.([a-f0-9]{24})\.json$/;
|
|
65
|
+
const minimumReleaseVersion = [3, 5, 1] as const;
|
|
66
|
+
|
|
67
|
+
const commandTimeouts: Record<TReleaseProtocolCommand, number> = {
|
|
68
|
+
"release-capabilities": 30_000,
|
|
69
|
+
"release-validate": 2 * 60_000,
|
|
70
|
+
"release-qualify": 6 * 60 * 60_000,
|
|
71
|
+
"release-probe": 5 * 60_000,
|
|
72
|
+
"release-promote": 2 * 60 * 60_000,
|
|
73
|
+
"release-cleanup": 10 * 60_000,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isPlainObject = (
|
|
77
|
+
valueArg: unknown,
|
|
78
|
+
): valueArg is Record<string, unknown> =>
|
|
79
|
+
typeof valueArg === "object" && valueArg !== null && !Array.isArray(valueArg);
|
|
80
|
+
|
|
81
|
+
export class TsdockerReleaseCommandError extends Error {
|
|
82
|
+
constructor(
|
|
83
|
+
public readonly command: TReleaseProtocolCommand,
|
|
84
|
+
public readonly code: string,
|
|
85
|
+
messageArg: string,
|
|
86
|
+
) {
|
|
87
|
+
super(messageArg);
|
|
88
|
+
this.name = "TsdockerReleaseCommandError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class TsdockerReleaseRequestCleanupError extends Error {
|
|
93
|
+
public reclamationError: unknown | undefined;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
public readonly command: TReleaseProtocolCommand,
|
|
97
|
+
public readonly requestPath: string,
|
|
98
|
+
public readonly commandError: unknown | undefined,
|
|
99
|
+
public readonly result: unknown | undefined,
|
|
100
|
+
public readonly cleanupError: unknown,
|
|
101
|
+
) {
|
|
102
|
+
super(`tSDocker ${command} request cleanup failed.`);
|
|
103
|
+
this.name = "TsdockerReleaseRequestCleanupError";
|
|
104
|
+
this.reclamationError = undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ITsdockerReleaseClient {
|
|
109
|
+
assertCapabilities: () => Promise<IReleaseCapabilitiesResult>;
|
|
110
|
+
validate: (
|
|
111
|
+
requestArg: IReleaseQualificationRequest,
|
|
112
|
+
requestDirectoryArg?: string,
|
|
113
|
+
) => Promise<IReleaseValidationResult>;
|
|
114
|
+
qualify: (
|
|
115
|
+
requestArg: IReleaseQualificationRequest,
|
|
116
|
+
requestDirectoryArg: string,
|
|
117
|
+
requestOwnerIdArg: string,
|
|
118
|
+
) => Promise<IReleaseQualificationResult>;
|
|
119
|
+
probe: (
|
|
120
|
+
requestArg: IReleaseQualificationRequest,
|
|
121
|
+
contextArg: IReleasePromotionContext,
|
|
122
|
+
requestDirectoryArg?: string,
|
|
123
|
+
requestOwnerIdArg?: string,
|
|
124
|
+
) => Promise<IReleaseProbeResult>;
|
|
125
|
+
promote: (
|
|
126
|
+
requestArg: IReleaseQualificationRequest,
|
|
127
|
+
contextArg: IReleasePromotionContext,
|
|
128
|
+
requestDirectoryArg: string,
|
|
129
|
+
requestOwnerIdArg: string,
|
|
130
|
+
) => Promise<IReleasePromotionResult>;
|
|
131
|
+
cleanup: (
|
|
132
|
+
requestArg: IReleaseQualificationRequest,
|
|
133
|
+
requestDirectoryArg: string,
|
|
134
|
+
requestOwnerIdArg: string,
|
|
135
|
+
) => Promise<IReleaseCleanupResult>;
|
|
136
|
+
reclaimRequestFiles: (
|
|
137
|
+
requestDirectoryArg: string,
|
|
138
|
+
retiredOwnerIdsArg: string[],
|
|
139
|
+
) => Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parseProtocolOutput = (
|
|
143
|
+
resultArg: plugins.smartshell.IExecResult,
|
|
144
|
+
commandArg: TReleaseProtocolCommand,
|
|
145
|
+
): unknown => {
|
|
146
|
+
if (resultArg.timedOut) {
|
|
147
|
+
throw new TsdockerReleaseCommandError(
|
|
148
|
+
commandArg,
|
|
149
|
+
"COMMAND_TIMEOUT",
|
|
150
|
+
`tSDocker ${commandArg} timed out with an unresolved outcome.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (resultArg.signal) {
|
|
154
|
+
throw new TsdockerReleaseCommandError(
|
|
155
|
+
commandArg,
|
|
156
|
+
"COMMAND_SIGNALLED",
|
|
157
|
+
`tSDocker ${commandArg} was terminated with an unresolved outcome.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (resultArg.stderr !== "") {
|
|
161
|
+
throw new TsdockerReleaseCommandError(
|
|
162
|
+
commandArg,
|
|
163
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
164
|
+
`tSDocker ${commandArg} wrote non-protocol stderr output.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
Buffer.byteLength(resultArg.stdout) > responseByteLimit ||
|
|
169
|
+
!resultArg.stdout.endsWith("\n")
|
|
170
|
+
) {
|
|
171
|
+
throw new TsdockerReleaseCommandError(
|
|
172
|
+
commandArg,
|
|
173
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
174
|
+
`tSDocker ${commandArg} returned an invalid response envelope.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let parsed: unknown;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(resultArg.stdout);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new TsdockerReleaseCommandError(
|
|
183
|
+
commandArg,
|
|
184
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
185
|
+
`tSDocker ${commandArg} returned invalid JSON.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (canonicalReleaseJsonDocument(parsed) !== resultArg.stdout) {
|
|
189
|
+
throw new TsdockerReleaseCommandError(
|
|
190
|
+
commandArg,
|
|
191
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
192
|
+
`tSDocker ${commandArg} returned noncanonical JSON.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (resultArg.exitCode !== 0) {
|
|
196
|
+
try {
|
|
197
|
+
const protocolError =
|
|
198
|
+
plugins.tsdockerReleaseProtocol.assertReleaseErrorResult(
|
|
199
|
+
parsed,
|
|
200
|
+
commandArg,
|
|
201
|
+
);
|
|
202
|
+
throw new TsdockerReleaseCommandError(
|
|
203
|
+
commandArg,
|
|
204
|
+
protocolError.error.code,
|
|
205
|
+
protocolError.error.message,
|
|
206
|
+
);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof TsdockerReleaseCommandError) throw error;
|
|
209
|
+
throw new TsdockerReleaseCommandError(
|
|
210
|
+
commandArg,
|
|
211
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
212
|
+
`tSDocker ${commandArg} failed without a valid protocol error.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
isPlainObject(parsed) &&
|
|
218
|
+
parsed.kind === "tsdocker-release-error-result"
|
|
219
|
+
) {
|
|
220
|
+
throw new TsdockerReleaseCommandError(
|
|
221
|
+
commandArg,
|
|
222
|
+
"INVALID_PROTOCOL_OUTPUT",
|
|
223
|
+
`tSDocker ${commandArg} returned an error result with exit code 0.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return parsed;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export class TsdockerReleaseClient implements ITsdockerReleaseClient {
|
|
230
|
+
private readonly binaryPath: string;
|
|
231
|
+
private readonly cwd: string;
|
|
232
|
+
private readonly packageJsonPath: string;
|
|
233
|
+
|
|
234
|
+
constructor(
|
|
235
|
+
private readonly smartshell: plugins.smartshell.Smartshell,
|
|
236
|
+
cwdArg: string,
|
|
237
|
+
) {
|
|
238
|
+
this.cwd = plugins.path.resolve(cwdArg);
|
|
239
|
+
this.binaryPath = getProjectTsdockerBinPath(this.cwd);
|
|
240
|
+
this.packageJsonPath = plugins.path.join(
|
|
241
|
+
this.cwd,
|
|
242
|
+
"node_modules",
|
|
243
|
+
"@git.zone",
|
|
244
|
+
"tsdocker",
|
|
245
|
+
"package.json",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public async assertCapabilities(): Promise<IReleaseCapabilitiesResult> {
|
|
250
|
+
const installedVersion = await this.readInstalledVersion();
|
|
251
|
+
await plugins.fs.access(this.binaryPath);
|
|
252
|
+
const versionResult = await this.smartshell.execSpawn(
|
|
253
|
+
this.binaryPath,
|
|
254
|
+
["--version"],
|
|
255
|
+
{
|
|
256
|
+
cwd: this.cwd,
|
|
257
|
+
maxBuffer: 4096,
|
|
258
|
+
silent: true,
|
|
259
|
+
timeout: 30_000,
|
|
260
|
+
timeoutKillGraceMs: 5_000,
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
if (
|
|
264
|
+
versionResult.exitCode !== 0 ||
|
|
265
|
+
versionResult.timedOut ||
|
|
266
|
+
versionResult.signal ||
|
|
267
|
+
versionResult.stderr !== "" ||
|
|
268
|
+
versionResult.stdout.trim() !== installedVersion
|
|
269
|
+
) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"Docker release journal schema 2 requires one matching project-local @git.zone/tsdocker package and binary.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const parsed = await this.run("release-capabilities");
|
|
276
|
+
return plugins.tsdockerReleaseProtocol.assertReleaseCapabilitiesResult(
|
|
277
|
+
parsed,
|
|
278
|
+
this.cwd,
|
|
279
|
+
{
|
|
280
|
+
requiredCommands: [
|
|
281
|
+
...plugins.tsdockerReleaseProtocol.releaseProtocolCommands,
|
|
282
|
+
],
|
|
283
|
+
requiredFeatures: [
|
|
284
|
+
...plugins.tsdockerReleaseProtocol.releaseProtocolFeatures,
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
public async validate(
|
|
291
|
+
requestArg: IReleaseQualificationRequest,
|
|
292
|
+
requestDirectoryArg?: string,
|
|
293
|
+
): Promise<IReleaseValidationResult> {
|
|
294
|
+
const request = this.assertRequestForProject(requestArg);
|
|
295
|
+
const runValidation = async (
|
|
296
|
+
requestDirectory: string,
|
|
297
|
+
requestOwnerId: string,
|
|
298
|
+
): Promise<IReleaseValidationResult> => {
|
|
299
|
+
const parsed = await this.run(
|
|
300
|
+
"release-validate",
|
|
301
|
+
request,
|
|
302
|
+
requestDirectory,
|
|
303
|
+
requestOwnerId,
|
|
304
|
+
);
|
|
305
|
+
return plugins.tsdockerReleaseProtocol.assertReleaseValidationResult(
|
|
306
|
+
parsed,
|
|
307
|
+
request,
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
if (requestDirectoryArg) {
|
|
311
|
+
return runValidation(
|
|
312
|
+
requestDirectoryArg,
|
|
313
|
+
plugins.crypto.randomBytes(16).toString("hex"),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return this.runWithTemporaryRequestDirectory(
|
|
317
|
+
"release-validate",
|
|
318
|
+
runValidation,
|
|
319
|
+
(valueArg) =>
|
|
320
|
+
plugins.tsdockerReleaseProtocol.assertReleaseValidationResult(
|
|
321
|
+
valueArg,
|
|
322
|
+
request,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
public async qualify(
|
|
328
|
+
requestArg: IReleaseQualificationRequest,
|
|
329
|
+
requestDirectoryArg: string,
|
|
330
|
+
requestOwnerIdArg: string,
|
|
331
|
+
): Promise<IReleaseQualificationResult> {
|
|
332
|
+
const request = this.assertRequestForProject(requestArg);
|
|
333
|
+
const parsed = await this.run(
|
|
334
|
+
"release-qualify",
|
|
335
|
+
request,
|
|
336
|
+
requestDirectoryArg,
|
|
337
|
+
requestOwnerIdArg,
|
|
338
|
+
["--classified-errors"],
|
|
339
|
+
);
|
|
340
|
+
return assertReleaseQualificationResult(parsed, request);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
public async probe(
|
|
344
|
+
requestArg: IReleaseQualificationRequest,
|
|
345
|
+
contextArg: IReleasePromotionContext,
|
|
346
|
+
requestDirectoryArg?: string,
|
|
347
|
+
requestOwnerIdArg?: string,
|
|
348
|
+
): Promise<IReleaseProbeResult> {
|
|
349
|
+
const request = this.assertPromotionInvocation(requestArg, contextArg);
|
|
350
|
+
const runProbe = async (
|
|
351
|
+
requestDirectory: string,
|
|
352
|
+
requestOwnerId: string,
|
|
353
|
+
): Promise<IReleaseProbeResult> => {
|
|
354
|
+
const parsed = await this.run(
|
|
355
|
+
"release-probe",
|
|
356
|
+
this.createPromotionRequest(
|
|
357
|
+
"tsdocker-release-probe-request",
|
|
358
|
+
request,
|
|
359
|
+
contextArg.promotionId,
|
|
360
|
+
),
|
|
361
|
+
requestDirectory,
|
|
362
|
+
requestOwnerId,
|
|
363
|
+
);
|
|
364
|
+
return plugins.tsdockerReleaseProtocol.assertReleaseProbeResult(
|
|
365
|
+
parsed,
|
|
366
|
+
request,
|
|
367
|
+
contextArg,
|
|
368
|
+
);
|
|
369
|
+
};
|
|
370
|
+
if (
|
|
371
|
+
(requestDirectoryArg === undefined) !==
|
|
372
|
+
(requestOwnerIdArg === undefined)
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
"tSDocker release probes require both request storage and owner, or neither.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (requestDirectoryArg && requestOwnerIdArg) {
|
|
379
|
+
return runProbe(requestDirectoryArg, requestOwnerIdArg);
|
|
380
|
+
}
|
|
381
|
+
return this.runWithTemporaryRequestDirectory(
|
|
382
|
+
"release-probe",
|
|
383
|
+
runProbe,
|
|
384
|
+
(valueArg) =>
|
|
385
|
+
plugins.tsdockerReleaseProtocol.assertReleaseProbeResult(
|
|
386
|
+
valueArg,
|
|
387
|
+
request,
|
|
388
|
+
contextArg,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
public async promote(
|
|
394
|
+
requestArg: IReleaseQualificationRequest,
|
|
395
|
+
contextArg: IReleasePromotionContext,
|
|
396
|
+
requestDirectoryArg: string,
|
|
397
|
+
requestOwnerIdArg: string,
|
|
398
|
+
): Promise<IReleasePromotionResult> {
|
|
399
|
+
const request = this.assertPromotionInvocation(requestArg, contextArg);
|
|
400
|
+
const parsed = await this.run(
|
|
401
|
+
"release-promote",
|
|
402
|
+
this.createPromotionRequest(
|
|
403
|
+
"tsdocker-release-promotion-request",
|
|
404
|
+
request,
|
|
405
|
+
contextArg.promotionId,
|
|
406
|
+
),
|
|
407
|
+
requestDirectoryArg,
|
|
408
|
+
requestOwnerIdArg,
|
|
409
|
+
);
|
|
410
|
+
return plugins.tsdockerReleaseProtocol.assertReleasePromotionResult(
|
|
411
|
+
parsed,
|
|
412
|
+
request,
|
|
413
|
+
contextArg,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public async cleanup(
|
|
418
|
+
requestArg: IReleaseQualificationRequest,
|
|
419
|
+
requestDirectoryArg: string,
|
|
420
|
+
requestOwnerIdArg: string,
|
|
421
|
+
): Promise<IReleaseCleanupResult> {
|
|
422
|
+
const request = this.assertRequestForProject(requestArg);
|
|
423
|
+
const parsed = await this.run(
|
|
424
|
+
"release-cleanup",
|
|
425
|
+
{
|
|
426
|
+
candidateId: request.candidateId,
|
|
427
|
+
kind: "tsdocker-release-cleanup-request",
|
|
428
|
+
projectId: request.projectId,
|
|
429
|
+
protocolVersion: 1,
|
|
430
|
+
schemaVersion: 1,
|
|
431
|
+
},
|
|
432
|
+
requestDirectoryArg,
|
|
433
|
+
requestOwnerIdArg,
|
|
434
|
+
);
|
|
435
|
+
return assertReleaseCleanupResult(parsed, request);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
public async reclaimRequestFiles(
|
|
439
|
+
requestDirectoryArg: string,
|
|
440
|
+
retiredOwnerIdsArg: string[],
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
if (
|
|
443
|
+
retiredOwnerIdsArg.length === 0 ||
|
|
444
|
+
new Set(retiredOwnerIdsArg).size !== retiredOwnerIdsArg.length ||
|
|
445
|
+
retiredOwnerIdsArg.some(
|
|
446
|
+
(ownerIdArg) => !requestOwnerIdPattern.test(ownerIdArg),
|
|
447
|
+
)
|
|
448
|
+
) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
"tSDocker request reclamation requires unique exact retired owner IDs.",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const requestDirectory =
|
|
454
|
+
await this.assertRequestDirectory(requestDirectoryArg);
|
|
455
|
+
const retiredOwnerIds = new Set(retiredOwnerIdsArg);
|
|
456
|
+
const callerUid = process.getuid?.();
|
|
457
|
+
const entries = await plugins.fs.readdir(requestDirectory, {
|
|
458
|
+
withFileTypes: true,
|
|
459
|
+
});
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
const match = entry.name.match(requestFilePattern);
|
|
462
|
+
if (!match || !retiredOwnerIds.has(match[1])) continue;
|
|
463
|
+
const requestPath = plugins.path.join(requestDirectory, entry.name);
|
|
464
|
+
let requestStat;
|
|
465
|
+
try {
|
|
466
|
+
requestStat = await plugins.fs.lstat(requestPath);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
if (
|
|
472
|
+
!entry.isFile() ||
|
|
473
|
+
!requestStat.isFile() ||
|
|
474
|
+
requestStat.isSymbolicLink() ||
|
|
475
|
+
(callerUid !== undefined && requestStat.uid !== callerUid) ||
|
|
476
|
+
(requestStat.mode & 0o077) !== 0 ||
|
|
477
|
+
requestStat.size > requestByteLimit
|
|
478
|
+
) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`Retired tSDocker request file is unsafe: ${requestPath}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
await plugins.fs.rm(requestPath);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async readInstalledVersion(): Promise<string> {
|
|
488
|
+
const packageHandle = await plugins.fs.open(
|
|
489
|
+
this.packageJsonPath,
|
|
490
|
+
plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_NOFOLLOW,
|
|
491
|
+
);
|
|
492
|
+
let packageJson: unknown;
|
|
493
|
+
try {
|
|
494
|
+
const before = await packageHandle.stat();
|
|
495
|
+
if (
|
|
496
|
+
!before.isFile() ||
|
|
497
|
+
before.size <= 0 ||
|
|
498
|
+
before.size > packageJsonByteLimit
|
|
499
|
+
) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"Project-local @git.zone/tsdocker package metadata is unavailable or unsafe.",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const bytes = await packageHandle.readFile();
|
|
505
|
+
const after = await packageHandle.stat();
|
|
506
|
+
if (
|
|
507
|
+
before.dev !== after.dev ||
|
|
508
|
+
before.ino !== after.ino ||
|
|
509
|
+
before.size !== after.size ||
|
|
510
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
511
|
+
before.ctimeMs !== after.ctimeMs ||
|
|
512
|
+
bytes.byteLength !== before.size
|
|
513
|
+
) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"Project-local @git.zone/tsdocker package metadata changed while being read.",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
packageJson = JSON.parse(
|
|
519
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
520
|
+
);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
"Project-local @git.zone/tsdocker package metadata is invalid.",
|
|
524
|
+
{ cause: error },
|
|
525
|
+
);
|
|
526
|
+
} finally {
|
|
527
|
+
await packageHandle.close();
|
|
528
|
+
}
|
|
529
|
+
if (
|
|
530
|
+
!isPlainObject(packageJson) ||
|
|
531
|
+
packageJson.name !== "@git.zone/tsdocker" ||
|
|
532
|
+
typeof packageJson.version !== "string" ||
|
|
533
|
+
!supportsTsdockerMinimumVersion(
|
|
534
|
+
packageJson.version,
|
|
535
|
+
minimumReleaseVersion,
|
|
536
|
+
)
|
|
537
|
+
) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Docker release journal schema 2 requires project-local @git.zone/tsdocker >= 3.5.1.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return packageJson.version;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private assertRequestForProject(
|
|
546
|
+
requestArg: IReleaseQualificationRequest,
|
|
547
|
+
): IReleaseQualificationRequest {
|
|
548
|
+
return plugins.tsdockerReleaseProtocol.assertReleaseQualificationRequestForProject(
|
|
549
|
+
requestArg,
|
|
550
|
+
this.cwd,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private assertPromotionInvocation(
|
|
555
|
+
requestArg: IReleaseQualificationRequest,
|
|
556
|
+
contextArg: IReleasePromotionContext,
|
|
557
|
+
): IReleaseQualificationRequest {
|
|
558
|
+
return plugins.tsdockerReleaseProtocol.assertReleasePromotionInvocation({
|
|
559
|
+
...contextArg,
|
|
560
|
+
request: requestArg,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private createPromotionRequest(
|
|
565
|
+
kindArg:
|
|
566
|
+
"tsdocker-release-probe-request" | "tsdocker-release-promotion-request",
|
|
567
|
+
requestArg: IReleaseQualificationRequest,
|
|
568
|
+
promotionIdArg: string,
|
|
569
|
+
): Record<string, unknown> {
|
|
570
|
+
return {
|
|
571
|
+
candidateId: requestArg.candidateId,
|
|
572
|
+
kind: kindArg,
|
|
573
|
+
projectId: requestArg.projectId,
|
|
574
|
+
promotionId: promotionIdArg,
|
|
575
|
+
protocolVersion: 1,
|
|
576
|
+
schemaVersion: 1,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async assertRequestDirectory(
|
|
581
|
+
requestDirectoryArg: string,
|
|
582
|
+
): Promise<string> {
|
|
583
|
+
const requestDirectory = plugins.path.resolve(requestDirectoryArg);
|
|
584
|
+
const directoryStat = await plugins.fs.lstat(requestDirectory);
|
|
585
|
+
const callerUid = process.getuid?.();
|
|
586
|
+
if (
|
|
587
|
+
!directoryStat.isDirectory() ||
|
|
588
|
+
directoryStat.isSymbolicLink() ||
|
|
589
|
+
(callerUid !== undefined && directoryStat.uid !== callerUid) ||
|
|
590
|
+
(directoryStat.mode & 0o077) !== 0
|
|
591
|
+
) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
"tSDocker release request storage must be a private owned directory.",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
return requestDirectory;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async runWithTemporaryRequestDirectory<T>(
|
|
600
|
+
commandArg: "release-validate" | "release-probe",
|
|
601
|
+
operationArg: (
|
|
602
|
+
requestDirectoryArg: string,
|
|
603
|
+
requestOwnerIdArg: string,
|
|
604
|
+
) => Promise<T>,
|
|
605
|
+
assertResultArg: (valueArg: unknown) => T,
|
|
606
|
+
): Promise<T> {
|
|
607
|
+
const temporaryRoot = await plugins.fs.realpath(plugins.os.tmpdir());
|
|
608
|
+
const temporaryDirectory = await plugins.fs.mkdtemp(
|
|
609
|
+
plugins.path.join(temporaryRoot, `gitzone-tsdocker-${commandArg}-`),
|
|
610
|
+
);
|
|
611
|
+
let result: T | undefined;
|
|
612
|
+
let operationError: unknown;
|
|
613
|
+
try {
|
|
614
|
+
await plugins.fs.chmod(temporaryDirectory, 0o700);
|
|
615
|
+
result = await operationArg(
|
|
616
|
+
temporaryDirectory,
|
|
617
|
+
plugins.crypto.randomBytes(16).toString("hex"),
|
|
618
|
+
);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
operationError = error;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let directoryCleanupError: unknown;
|
|
624
|
+
try {
|
|
625
|
+
await plugins.fs.rm(temporaryDirectory, {
|
|
626
|
+
recursive: true,
|
|
627
|
+
force: false,
|
|
628
|
+
});
|
|
629
|
+
} catch (error) {
|
|
630
|
+
directoryCleanupError = error;
|
|
631
|
+
}
|
|
632
|
+
if (directoryCleanupError) {
|
|
633
|
+
if (operationError instanceof TsdockerReleaseRequestCleanupError) {
|
|
634
|
+
operationError.reclamationError = directoryCleanupError;
|
|
635
|
+
throw operationError;
|
|
636
|
+
}
|
|
637
|
+
throw new TsdockerReleaseRequestCleanupError(
|
|
638
|
+
commandArg,
|
|
639
|
+
temporaryDirectory,
|
|
640
|
+
operationError,
|
|
641
|
+
result,
|
|
642
|
+
directoryCleanupError,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (operationError instanceof TsdockerReleaseRequestCleanupError) {
|
|
646
|
+
if (operationError.result !== undefined) {
|
|
647
|
+
return assertResultArg(operationError.result);
|
|
648
|
+
}
|
|
649
|
+
if (operationError.commandError !== undefined) {
|
|
650
|
+
throw operationError.commandError;
|
|
651
|
+
}
|
|
652
|
+
throw operationError;
|
|
653
|
+
}
|
|
654
|
+
if (operationError) throw operationError;
|
|
655
|
+
return result!;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private async run(
|
|
659
|
+
commandArg: TReleaseProtocolCommand,
|
|
660
|
+
requestArg?: unknown,
|
|
661
|
+
requestDirectoryArg?: string,
|
|
662
|
+
requestOwnerIdArg?: string,
|
|
663
|
+
extraArgsArg: string[] = [],
|
|
664
|
+
): Promise<unknown> {
|
|
665
|
+
let requestPath = "";
|
|
666
|
+
let requestCreated = false;
|
|
667
|
+
let requestIdentity:
|
|
668
|
+
{ dev: number; ino: number; mode: number; uid: number } | undefined;
|
|
669
|
+
let commandError: unknown;
|
|
670
|
+
let result: unknown;
|
|
671
|
+
let hasResult = false;
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
const args: string[] = [commandArg];
|
|
675
|
+
if (requestArg !== undefined) {
|
|
676
|
+
if (
|
|
677
|
+
!requestDirectoryArg ||
|
|
678
|
+
!requestOwnerIdArg ||
|
|
679
|
+
!requestOwnerIdPattern.test(requestOwnerIdArg)
|
|
680
|
+
) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
"tSDocker release request storage and exact owner are required.",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const requestDirectory =
|
|
686
|
+
await this.assertRequestDirectory(requestDirectoryArg);
|
|
687
|
+
requestPath = plugins.path.join(
|
|
688
|
+
requestDirectory,
|
|
689
|
+
`.tsdocker-request.${requestOwnerIdArg}.${commandArg}.${plugins.crypto
|
|
690
|
+
.randomBytes(12)
|
|
691
|
+
.toString("hex")}.json`,
|
|
692
|
+
);
|
|
693
|
+
const document = canonicalReleaseJsonDocument(requestArg);
|
|
694
|
+
if (Buffer.byteLength(document) > requestByteLimit) {
|
|
695
|
+
throw new Error("tSDocker release request exceeds 64 KiB.");
|
|
696
|
+
}
|
|
697
|
+
const callerUid = process.getuid?.();
|
|
698
|
+
const handle = await plugins.fs.open(
|
|
699
|
+
requestPath,
|
|
700
|
+
plugins.fsConstants.O_WRONLY |
|
|
701
|
+
plugins.fsConstants.O_CREAT |
|
|
702
|
+
plugins.fsConstants.O_EXCL |
|
|
703
|
+
plugins.fsConstants.O_NOFOLLOW,
|
|
704
|
+
0o600,
|
|
705
|
+
);
|
|
706
|
+
requestCreated = true;
|
|
707
|
+
try {
|
|
708
|
+
const openedStat = await handle.stat();
|
|
709
|
+
if (
|
|
710
|
+
!openedStat.isFile() ||
|
|
711
|
+
(callerUid !== undefined && openedStat.uid !== callerUid) ||
|
|
712
|
+
(openedStat.mode & 0o077) !== 0
|
|
713
|
+
) {
|
|
714
|
+
throw new Error("tSDocker release request file is unsafe.");
|
|
715
|
+
}
|
|
716
|
+
requestIdentity = {
|
|
717
|
+
dev: openedStat.dev,
|
|
718
|
+
ino: openedStat.ino,
|
|
719
|
+
mode: openedStat.mode,
|
|
720
|
+
uid: openedStat.uid,
|
|
721
|
+
};
|
|
722
|
+
await handle.writeFile(document, "utf8");
|
|
723
|
+
await handle.sync();
|
|
724
|
+
const requestStat = await handle.stat();
|
|
725
|
+
if (
|
|
726
|
+
!requestStat.isFile() ||
|
|
727
|
+
(callerUid !== undefined && requestStat.uid !== callerUid) ||
|
|
728
|
+
(requestStat.mode & 0o077) !== 0 ||
|
|
729
|
+
requestStat.size !== Buffer.byteLength(document)
|
|
730
|
+
) {
|
|
731
|
+
throw new Error("tSDocker release request file is unsafe.");
|
|
732
|
+
}
|
|
733
|
+
requestIdentity = {
|
|
734
|
+
dev: requestStat.dev,
|
|
735
|
+
ino: requestStat.ino,
|
|
736
|
+
mode: requestStat.mode,
|
|
737
|
+
uid: requestStat.uid,
|
|
738
|
+
};
|
|
739
|
+
} finally {
|
|
740
|
+
await handle.close();
|
|
741
|
+
}
|
|
742
|
+
args.push(`--request-file=${requestPath}`, ...extraArgsArg);
|
|
743
|
+
} else if (extraArgsArg.length > 0) {
|
|
744
|
+
throw new Error("tSDocker request flags require a request payload.");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const commandResult = await this.smartshell.execSpawn(
|
|
748
|
+
this.binaryPath,
|
|
749
|
+
args,
|
|
750
|
+
{
|
|
751
|
+
cwd: this.cwd,
|
|
752
|
+
maxBuffer: responseByteLimit + 1,
|
|
753
|
+
silent: true,
|
|
754
|
+
timeout: commandTimeouts[commandArg],
|
|
755
|
+
timeoutKillGraceMs:
|
|
756
|
+
commandArg === "release-qualify" || commandArg === "release-promote"
|
|
757
|
+
? 3 * 60_000
|
|
758
|
+
: 30_000,
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
result = parseProtocolOutput(commandResult, commandArg);
|
|
762
|
+
hasResult = true;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
commandError = error;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
let cleanupError: unknown;
|
|
768
|
+
if (requestCreated) {
|
|
769
|
+
try {
|
|
770
|
+
if (!requestIdentity) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
"tSDocker release request was created without a cleanup identity.",
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
const currentStat = await plugins.fs.lstat(requestPath);
|
|
776
|
+
if (
|
|
777
|
+
!currentStat.isFile() ||
|
|
778
|
+
currentStat.isSymbolicLink() ||
|
|
779
|
+
currentStat.dev !== requestIdentity.dev ||
|
|
780
|
+
currentStat.ino !== requestIdentity.ino ||
|
|
781
|
+
currentStat.mode !== requestIdentity.mode ||
|
|
782
|
+
currentStat.uid !== requestIdentity.uid
|
|
783
|
+
) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
"tSDocker release request changed before caller cleanup.",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
await plugins.fs.rm(requestPath);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
cleanupError = error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (cleanupError) {
|
|
795
|
+
throw new TsdockerReleaseRequestCleanupError(
|
|
796
|
+
commandArg,
|
|
797
|
+
requestPath,
|
|
798
|
+
commandError,
|
|
799
|
+
hasResult ? result : undefined,
|
|
800
|
+
cleanupError,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (commandError) throw commandError;
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
}
|