@git.zone/cli 3.2.0 → 4.0.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 +2 -2
- package/dist_ts/classes.gitzoneconfig.js +1 -1
- package/dist_ts/classes.project.js +1 -1
- package/dist_ts/gitzone.cli.js +1 -1
- package/dist_ts/gitzone.logging.js +1 -1
- package/dist_ts/helpers.changelog.js +1 -1
- package/dist_ts/helpers.climode.js +1 -1
- package/dist_ts/helpers.smartconfig.js +61 -11
- package/dist_ts/helpers.smartconfigmigrations.js +1 -1
- package/dist_ts/helpers.tsdocker.js +1 -1
- package/dist_ts/helpers.workflow.js +1 -1
- package/dist_ts/index.js +1 -1
- package/dist_ts/mod_audit/index.js +1 -1
- package/dist_ts/mod_commit/index.js +1 -1
- package/dist_ts/mod_commit/mod.helpers.js +1 -1
- package/dist_ts/mod_commit/mod.plugins.js +1 -1
- package/dist_ts/mod_commit/mod.ui.js +1 -1
- package/dist_ts/mod_config/classes.commitconfig.js +1 -1
- package/dist_ts/mod_config/classes.releaseconfig.js +1 -1
- package/dist_ts/mod_config/index.js +2 -2
- package/dist_ts/mod_config/mod.plugins.js +1 -1
- package/dist_ts/mod_deprecate/index.js +1 -1
- package/dist_ts/mod_deprecate/mod.plugins.js +1 -1
- package/dist_ts/mod_docker/classes.dockerpruner.js +7 -1
- package/dist_ts/mod_docker/index.js +1 -1
- package/dist_ts/mod_docker/mod.plugins.js +1 -1
- package/dist_ts/mod_format/classes.baseformatter.js +1 -1
- package/dist_ts/mod_format/classes.diffreporter.js +1 -1
- package/dist_ts/mod_format/classes.formatcontext.js +1 -1
- package/dist_ts/mod_format/classes.formatplanner.js +1 -1
- package/dist_ts/mod_format/classes.formatstats.js +1 -1
- package/dist_ts/mod_format/formatters/assets.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/cleanup.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/copy.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/gitignore.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/license.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/packagejson.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/prettier.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/readme.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/smartconfig.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/templates.formatter.js +1 -1
- package/dist_ts/mod_format/formatters/tsconfig.formatter.js +1 -1
- package/dist_ts/mod_format/index.js +1 -1
- package/dist_ts/mod_format/interfaces.format.js +1 -1
- package/dist_ts/mod_format/mod.plugins.js +1 -1
- package/dist_ts/mod_helpers/index.js +1 -1
- package/dist_ts/mod_helpers/mod.plugins.js +1 -1
- package/dist_ts/mod_meta/index.js +1 -1
- package/dist_ts/mod_meta/meta.classes.meta.js +1 -1
- package/dist_ts/mod_meta/meta.interfaces.js +1 -1
- package/dist_ts/mod_meta/meta.plugins.js +1 -1
- package/dist_ts/mod_open/index.js +1 -1
- package/dist_ts/mod_open/mod.plugins.js +1 -1
- package/dist_ts/mod_release/index.js +4 -4
- package/dist_ts/mod_release/mod.plugins.js +1 -1
- package/dist_ts/mod_services/classes.dockercontainer.d.ts +50 -3
- package/dist_ts/mod_services/classes.dockercontainer.js +646 -84
- package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -24
- package/dist_ts/mod_services/classes.globalregistry.js +210 -62
- package/dist_ts/mod_services/classes.globalregistrystore.d.ts +48 -0
- package/dist_ts/mod_services/classes.globalregistrystore.js +156 -0
- package/dist_ts/mod_services/classes.interprocesslock.d.ts +21 -0
- package/dist_ts/mod_services/classes.interprocesslock.js +141 -0
- package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +15 -12
- package/dist_ts/mod_services/classes.serviceconfiguration.js +188 -91
- package/dist_ts/mod_services/classes.servicedatamarker.d.ts +32 -7
- package/dist_ts/mod_services/classes.servicedatamarker.js +145 -49
- package/dist_ts/mod_services/classes.servicemanager.d.ts +70 -28
- package/dist_ts/mod_services/classes.servicemanager.js +995 -421
- package/dist_ts/mod_services/classes.servicenames.d.ts +8 -0
- package/dist_ts/mod_services/classes.servicenames.js +54 -0
- package/dist_ts/mod_services/classes.serviceoptions.js +1 -1
- package/dist_ts/mod_services/classes.servicepruner.d.ts +3 -0
- package/dist_ts/mod_services/classes.servicepruner.js +208 -46
- package/dist_ts/mod_services/helpers.js +1 -1
- package/dist_ts/mod_services/index.d.ts +2 -0
- package/dist_ts/mod_services/index.js +81 -58
- package/dist_ts/mod_services/mod.plugins.js +1 -1
- package/dist_ts/mod_standard/index.js +2 -2
- package/dist_ts/mod_standard/mod.plugins.js +1 -1
- package/dist_ts/mod_start/index.js +1 -1
- package/dist_ts/mod_start/mod.plugins.js +1 -1
- package/dist_ts/mod_template/index.js +1 -1
- package/dist_ts/mod_template/mod.plugins.js +1 -1
- package/dist_ts/mod_tools/classes.packagemanager.js +1 -1
- package/dist_ts/mod_tools/index.js +1 -1
- package/dist_ts/mod_tools/mod.plugins.js +1 -1
- package/dist_ts/paths.js +1 -1
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +4 -2
- package/dist_ts_migration/001.service-selection.d.ts +9 -0
- package/dist_ts_migration/001.service-selection.js +51 -0
- package/dist_ts_migration/002.runtime-config.d.ts +19 -0
- package/dist_ts_migration/002.runtime-config.js +170 -0
- package/dist_ts_migration/003.service-data-marker.d.ts +9 -0
- package/dist_ts_migration/003.service-data-marker.js +156 -0
- package/dist_ts_migration/004.global-registry.d.ts +13 -0
- package/dist_ts_migration/004.global-registry.js +165 -0
- package/dist_ts_migration/index.d.ts +11 -0
- package/dist_ts_migration/index.js +59 -0
- package/package.json +6 -3
- package/readme.hints.md +102 -25
- package/readme.md +123 -28
- package/readme.plan.md +29 -16
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.smartconfig.ts +63 -10
- package/ts/mod_config/index.ts +1 -1
- package/ts/mod_docker/classes.dockerpruner.ts +6 -0
- package/ts/mod_release/index.ts +3 -3
- package/ts/mod_services/classes.dockercontainer.ts +912 -96
- package/ts/mod_services/classes.globalregistry.ts +290 -89
- package/ts/mod_services/classes.globalregistrystore.ts +279 -0
- package/ts/mod_services/classes.interprocesslock.ts +189 -0
- package/ts/mod_services/classes.serviceconfiguration.ts +215 -108
- package/ts/mod_services/classes.servicedatamarker.ts +210 -52
- package/ts/mod_services/classes.servicemanager.ts +1332 -512
- package/ts/mod_services/classes.servicenames.ts +76 -0
- package/ts/mod_services/classes.servicepruner.ts +310 -52
- package/ts/mod_services/index.ts +89 -62
- package/ts/mod_standard/index.ts +1 -1
- package/ts/plugins.ts +4 -0
- package/ts_migration/001.service-selection.ts +77 -0
- package/ts_migration/002.runtime-config.ts +218 -0
- package/ts_migration/003.service-data-marker.ts +216 -0
- package/ts_migration/004.global-registry.ts +272 -0
- package/ts_migration/index.ts +83 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { InterProcessLock } from './classes.interprocesslock.js';
|
|
3
|
+
|
|
4
|
+
export interface IRegisteredProject {
|
|
5
|
+
projectPath: string;
|
|
6
|
+
projectName: string;
|
|
7
|
+
containers: {
|
|
8
|
+
mongo?: string;
|
|
9
|
+
objectstorage?: string;
|
|
10
|
+
elasticsearch?: string;
|
|
11
|
+
};
|
|
12
|
+
ports: {
|
|
13
|
+
mongo?: number;
|
|
14
|
+
s3?: number;
|
|
15
|
+
s3Ui?: number;
|
|
16
|
+
elasticsearch?: number;
|
|
17
|
+
};
|
|
18
|
+
legacy?: {
|
|
19
|
+
/** Preserved migration evidence. Never passed to an active lifecycle operation. */
|
|
20
|
+
minioContainer?: string;
|
|
21
|
+
};
|
|
22
|
+
enabledServices: string[];
|
|
23
|
+
lastActive: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IGlobalRegistryData {
|
|
27
|
+
schemaVersion: 2;
|
|
28
|
+
projects: { [projectPath: string]: IRegisteredProject };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
32
|
+
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const assertAllowedKeys = (
|
|
36
|
+
valueArg: Record<string, unknown>,
|
|
37
|
+
allowedKeysArg: readonly string[],
|
|
38
|
+
contextArg: string,
|
|
39
|
+
): void => {
|
|
40
|
+
if (Object.keys(valueArg).some((keyArg) => !allowedKeysArg.includes(keyArg))) {
|
|
41
|
+
throw new Error(`Global services registry ${contextArg} contains unsupported fields.`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const assertOptionalStrings = (
|
|
46
|
+
valueArg: Record<string, unknown>,
|
|
47
|
+
keysArg: readonly string[],
|
|
48
|
+
contextArg: string,
|
|
49
|
+
): void => {
|
|
50
|
+
for (const key of keysArg) {
|
|
51
|
+
if (valueArg[key] !== undefined && (typeof valueArg[key] !== 'string' || !valueArg[key])) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Global services registry ${contextArg}.${key} must be a non-empty string.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const assertOptionalNumbers = (
|
|
60
|
+
valueArg: Record<string, unknown>,
|
|
61
|
+
keysArg: readonly string[],
|
|
62
|
+
contextArg: string,
|
|
63
|
+
): void => {
|
|
64
|
+
for (const key of keysArg) {
|
|
65
|
+
const value = valueArg[key];
|
|
66
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Global services registry ${contextArg}.${key} must be a finite number.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Validate the canonical runtime schema after startup migrations have completed. */
|
|
75
|
+
export const assertCanonicalGlobalRegistryData = (
|
|
76
|
+
dataArg: unknown,
|
|
77
|
+
): IGlobalRegistryData => {
|
|
78
|
+
if (!isPlainObject(dataArg)) {
|
|
79
|
+
throw new Error('Global services registry root must be a JSON object.');
|
|
80
|
+
}
|
|
81
|
+
assertAllowedKeys(dataArg, ['schemaVersion', 'projects'], 'root');
|
|
82
|
+
if (dataArg.schemaVersion !== 2 || !isPlainObject(dataArg.projects)) {
|
|
83
|
+
throw new Error('Global services registry requires canonical schema version 2.');
|
|
84
|
+
}
|
|
85
|
+
for (const [projectPath, rawProject] of Object.entries(dataArg.projects)) {
|
|
86
|
+
if (!isPlainObject(rawProject)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Global services registry project ${projectPath} must be a JSON object.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
assertAllowedKeys(
|
|
92
|
+
rawProject,
|
|
93
|
+
[
|
|
94
|
+
'projectPath',
|
|
95
|
+
'projectName',
|
|
96
|
+
'containers',
|
|
97
|
+
'ports',
|
|
98
|
+
'legacy',
|
|
99
|
+
'enabledServices',
|
|
100
|
+
'lastActive',
|
|
101
|
+
],
|
|
102
|
+
`project ${projectPath}`,
|
|
103
|
+
);
|
|
104
|
+
if (
|
|
105
|
+
typeof rawProject.projectPath !== 'string' ||
|
|
106
|
+
!rawProject.projectPath ||
|
|
107
|
+
plugins.path.resolve(rawProject.projectPath) !== plugins.path.resolve(projectPath)
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Global services registry project ${projectPath} has an invalid or inconsistent path.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (typeof rawProject.projectName !== 'string' || !rawProject.projectName) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Global services registry project ${projectPath} has an invalid name.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!isPlainObject(rawProject.containers) || !isPlainObject(rawProject.ports)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Global services registry project ${projectPath} requires container and port objects.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(rawProject.enabledServices)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Global services registry project ${projectPath} has invalid enabled services.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const canonicalServices = ['mongodb', 'objectstorage', 'elasticsearch'];
|
|
129
|
+
if (
|
|
130
|
+
!rawProject.enabledServices.every(
|
|
131
|
+
(serviceArg) =>
|
|
132
|
+
typeof serviceArg === 'string' && canonicalServices.includes(serviceArg),
|
|
133
|
+
) ||
|
|
134
|
+
new Set(rawProject.enabledServices).size !== rawProject.enabledServices.length
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Global services registry project ${projectPath} has noncanonical enabled services.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (typeof rawProject.lastActive !== 'number' || !Number.isFinite(rawProject.lastActive)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Global services registry project ${projectPath} has an invalid lastActive value.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const rawContainers = rawProject.containers;
|
|
146
|
+
const rawPorts = rawProject.ports;
|
|
147
|
+
assertAllowedKeys(
|
|
148
|
+
rawContainers,
|
|
149
|
+
['mongo', 'objectstorage', 'elasticsearch'],
|
|
150
|
+
`project ${projectPath} containers`,
|
|
151
|
+
);
|
|
152
|
+
assertOptionalStrings(
|
|
153
|
+
rawContainers,
|
|
154
|
+
['mongo', 'objectstorage', 'elasticsearch'],
|
|
155
|
+
`project ${projectPath}.containers`,
|
|
156
|
+
);
|
|
157
|
+
assertAllowedKeys(
|
|
158
|
+
rawPorts,
|
|
159
|
+
['mongo', 's3', 's3Ui', 'elasticsearch'],
|
|
160
|
+
`project ${projectPath} ports`,
|
|
161
|
+
);
|
|
162
|
+
assertOptionalNumbers(
|
|
163
|
+
rawPorts,
|
|
164
|
+
['mongo', 's3', 's3Ui', 'elasticsearch'],
|
|
165
|
+
`project ${projectPath}.ports`,
|
|
166
|
+
);
|
|
167
|
+
if (rawProject.legacy !== undefined) {
|
|
168
|
+
if (!isPlainObject(rawProject.legacy)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Global services registry project ${projectPath} has invalid legacy evidence.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
assertAllowedKeys(rawProject.legacy, ['minioContainer'], `project ${projectPath} legacy`);
|
|
174
|
+
assertOptionalStrings(
|
|
175
|
+
rawProject.legacy,
|
|
176
|
+
['minioContainer'],
|
|
177
|
+
`project ${projectPath}.legacy`,
|
|
178
|
+
);
|
|
179
|
+
if (!rawProject.legacy.minioContainer) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Global services registry project ${projectPath} has empty legacy evidence.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return dataArg as unknown as IGlobalRegistryData;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export interface IGlobalRegistryTransactionResult<T> {
|
|
190
|
+
result: T;
|
|
191
|
+
writeData?: IGlobalRegistryData;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface IGlobalRegistryStoreOptions {
|
|
195
|
+
filePath?: string;
|
|
196
|
+
lockTimeoutMs?: number;
|
|
197
|
+
lockPollMs?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Raw, atomically replaced registry storage shared by runtime and migrations. */
|
|
201
|
+
export class GlobalRegistryStore {
|
|
202
|
+
public readonly filePath: string;
|
|
203
|
+
private readonly lock: InterProcessLock;
|
|
204
|
+
|
|
205
|
+
constructor(optionsArg: IGlobalRegistryStoreOptions = {}) {
|
|
206
|
+
this.filePath = plugins.path.resolve(
|
|
207
|
+
optionsArg.filePath ||
|
|
208
|
+
plugins.path.join(
|
|
209
|
+
plugins.smartpath.get.home(),
|
|
210
|
+
'.smartconfig',
|
|
211
|
+
'kv',
|
|
212
|
+
'gitzone-services.json',
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
this.lock = new InterProcessLock({
|
|
216
|
+
lockPath: `${this.filePath}.lock`,
|
|
217
|
+
description: 'Global services registry',
|
|
218
|
+
timeoutMs: optionsArg.lockTimeoutMs,
|
|
219
|
+
pollMs: optionsArg.lockPollMs,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public async readRaw(): Promise<unknown> {
|
|
224
|
+
let content: string;
|
|
225
|
+
try {
|
|
226
|
+
content = await plugins.fs.readFile(this.filePath, 'utf8');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
throw new Error('Global services registry could not be read.', { cause: error });
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(content);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw new Error('Global services registry contains invalid JSON.', { cause: error });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public async readCanonical(): Promise<IGlobalRegistryData> {
|
|
241
|
+
return assertCanonicalGlobalRegistryData(await this.readRaw());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public async transact<T>(
|
|
245
|
+
operationArg: (
|
|
246
|
+
rawDataArg: unknown,
|
|
247
|
+
) => Promise<IGlobalRegistryTransactionResult<T>> | IGlobalRegistryTransactionResult<T>,
|
|
248
|
+
): Promise<T> {
|
|
249
|
+
return this.lock.runExclusive(async () => {
|
|
250
|
+
const transaction = await operationArg(await this.readRaw());
|
|
251
|
+
if (transaction.writeData) {
|
|
252
|
+
await this.writeAtomic(assertCanonicalGlobalRegistryData(transaction.writeData));
|
|
253
|
+
}
|
|
254
|
+
return transaction.result;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async writeAtomic(dataArg: IGlobalRegistryData): Promise<void> {
|
|
259
|
+
await plugins.fs.mkdir(plugins.path.dirname(this.filePath), { recursive: true });
|
|
260
|
+
const tempPath = `${this.filePath}.tmp-${process.pid}-${plugins.crypto
|
|
261
|
+
.randomBytes(12)
|
|
262
|
+
.toString('hex')}`;
|
|
263
|
+
let handle: Awaited<ReturnType<typeof plugins.fs.open>> | undefined;
|
|
264
|
+
try {
|
|
265
|
+
handle = await plugins.fs.open(tempPath, 'wx', 0o600);
|
|
266
|
+
await handle.writeFile(`${JSON.stringify(dataArg, null, 2)}\n`, 'utf8');
|
|
267
|
+
await handle.sync();
|
|
268
|
+
await handle.close();
|
|
269
|
+
handle = undefined;
|
|
270
|
+
await plugins.fs.rename(tempPath, this.filePath);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
await handle?.close().catch(() => {});
|
|
273
|
+
await plugins.fs.rm(tempPath, { force: true }).catch(() => {});
|
|
274
|
+
throw new Error('Global services registry could not be written atomically.', {
|
|
275
|
+
cause: error,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import type { TServiceName } from './classes.servicedatamarker.js';
|
|
3
|
+
|
|
4
|
+
interface ILockOwner {
|
|
5
|
+
token: string;
|
|
6
|
+
pid: number;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IInterProcessLockOptions {
|
|
11
|
+
lockPath: string;
|
|
12
|
+
description: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
pollMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> => {
|
|
18
|
+
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Atomic-mkdir lock with bounded waiting and manual-only stale-lock recovery. */
|
|
22
|
+
export class InterProcessLock {
|
|
23
|
+
public readonly lockPath: string;
|
|
24
|
+
private readonly description: string;
|
|
25
|
+
private readonly timeoutMs: number;
|
|
26
|
+
private readonly pollMs: number;
|
|
27
|
+
|
|
28
|
+
constructor(optionsArg: IInterProcessLockOptions) {
|
|
29
|
+
this.lockPath = plugins.path.resolve(optionsArg.lockPath);
|
|
30
|
+
this.description = optionsArg.description;
|
|
31
|
+
this.timeoutMs = optionsArg.timeoutMs ?? 5_000;
|
|
32
|
+
this.pollMs = optionsArg.pollMs ?? 25;
|
|
33
|
+
if (!this.description) {
|
|
34
|
+
throw new Error('Interprocess lock description must not be empty.');
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs <= 0) {
|
|
37
|
+
throw new Error('Interprocess lock timeout must be a positive safe integer.');
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isSafeInteger(this.pollMs) || this.pollMs <= 0) {
|
|
40
|
+
throw new Error('Interprocess lock poll interval must be a positive safe integer.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async runExclusive<T>(operationArg: () => Promise<T>): Promise<T> {
|
|
45
|
+
const release = await this.acquire();
|
|
46
|
+
let result: T | undefined;
|
|
47
|
+
let primaryError: unknown;
|
|
48
|
+
try {
|
|
49
|
+
result = await operationArg();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
primaryError = error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let releaseError: unknown;
|
|
55
|
+
try {
|
|
56
|
+
await release();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
releaseError = error;
|
|
59
|
+
}
|
|
60
|
+
if (primaryError && releaseError) {
|
|
61
|
+
throw new AggregateError(
|
|
62
|
+
[primaryError, releaseError],
|
|
63
|
+
`${this.description} failed and its lock could not be released.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (primaryError) {
|
|
67
|
+
throw primaryError;
|
|
68
|
+
}
|
|
69
|
+
if (releaseError) {
|
|
70
|
+
throw releaseError;
|
|
71
|
+
}
|
|
72
|
+
return result as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async acquire(): Promise<() => Promise<void>> {
|
|
76
|
+
await plugins.fs.mkdir(plugins.path.dirname(this.lockPath), { recursive: true });
|
|
77
|
+
const owner: ILockOwner = {
|
|
78
|
+
token: plugins.crypto.randomBytes(16).toString('hex'),
|
|
79
|
+
pid: process.pid,
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
const ownerPath = plugins.path.join(this.lockPath, 'owner.json');
|
|
83
|
+
const deadlineAt = Date.now() + this.timeoutMs;
|
|
84
|
+
|
|
85
|
+
while (true) {
|
|
86
|
+
try {
|
|
87
|
+
await plugins.fs.mkdir(this.lockPath);
|
|
88
|
+
try {
|
|
89
|
+
await plugins.fs.writeFile(ownerPath, `${JSON.stringify(owner)}\n`, {
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
flag: 'wx',
|
|
92
|
+
mode: 0o600,
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await this.release(owner.token, false).catch(() => {});
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return async () => this.release(owner.token, true);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
101
|
+
throw new Error(`${this.description} lock could not be acquired.`, { cause: error });
|
|
102
|
+
}
|
|
103
|
+
const remaining = deadlineAt - Date.now();
|
|
104
|
+
if (remaining <= 0) {
|
|
105
|
+
let ownerDescription = 'owner metadata unavailable';
|
|
106
|
+
try {
|
|
107
|
+
const existingOwner = JSON.parse(await plugins.fs.readFile(ownerPath, 'utf8')) as {
|
|
108
|
+
pid?: unknown;
|
|
109
|
+
createdAt?: unknown;
|
|
110
|
+
};
|
|
111
|
+
if (
|
|
112
|
+
typeof existingOwner.pid === 'number' &&
|
|
113
|
+
typeof existingOwner.createdAt === 'string'
|
|
114
|
+
) {
|
|
115
|
+
ownerDescription = `pid ${existingOwner.pid}, created ${existingOwner.createdAt}`;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Incomplete stale locks remain fail-closed and require operator review.
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${this.description} is locked (${ownerDescription}). ` +
|
|
122
|
+
`If that process no longer exists, inspect and remove ${this.lockPath}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
await plugins.smartdelay.delayFor(Math.min(this.pollMs, remaining));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async release(tokenArg: string, requireOwnerArg: boolean): Promise<void> {
|
|
131
|
+
const ownerPath = plugins.path.join(this.lockPath, 'owner.json');
|
|
132
|
+
if (requireOwnerArg) {
|
|
133
|
+
let owner: unknown;
|
|
134
|
+
try {
|
|
135
|
+
owner = JSON.parse(await plugins.fs.readFile(ownerPath, 'utf8'));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`${this.description} lock ownership could not be verified.`, {
|
|
138
|
+
cause: error,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (!isPlainObject(owner) || owner.token !== tokenArg) {
|
|
142
|
+
throw new Error(`${this.description} lock ownership changed before release.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tombstonePath = `${this.lockPath}.release-${tokenArg}`;
|
|
147
|
+
await plugins.fs.rename(this.lockPath, tombstonePath);
|
|
148
|
+
await plugins.fs.rm(tombstonePath, { recursive: true, force: false });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const withInterProcessLock = async <T>(
|
|
153
|
+
optionsArg: IInterProcessLockOptions,
|
|
154
|
+
operationArg: () => Promise<T>,
|
|
155
|
+
): Promise<T> => {
|
|
156
|
+
return new InterProcessLock(optionsArg).runExclusive(operationArg);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const getServiceDataOperationLockPath = (
|
|
160
|
+
projectPathArg: string,
|
|
161
|
+
serviceArg: TServiceName,
|
|
162
|
+
): string => {
|
|
163
|
+
const projectHash = plugins.crypto
|
|
164
|
+
.createHash('sha256')
|
|
165
|
+
.update(plugins.path.resolve(projectPathArg))
|
|
166
|
+
.digest('hex')
|
|
167
|
+
.slice(0, 32);
|
|
168
|
+
return plugins.path.join(
|
|
169
|
+
plugins.smartpath.get.home(),
|
|
170
|
+
'.smartconfig',
|
|
171
|
+
'kv',
|
|
172
|
+
'gitzone-services-locks',
|
|
173
|
+
`${projectHash}-${serviceArg}.lock`,
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const withServiceDataOperationLock = async <T>(
|
|
178
|
+
projectPathArg: string,
|
|
179
|
+
serviceArg: TServiceName,
|
|
180
|
+
operationArg: () => Promise<T>,
|
|
181
|
+
): Promise<T> => {
|
|
182
|
+
return withInterProcessLock(
|
|
183
|
+
{
|
|
184
|
+
lockPath: getServiceDataOperationLockPath(projectPathArg, serviceArg),
|
|
185
|
+
description: `${serviceArg} service data operation`,
|
|
186
|
+
},
|
|
187
|
+
operationArg,
|
|
188
|
+
);
|
|
189
|
+
};
|