agl 21.0.2 → 22.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/changelog.md +9 -0
- package/dist_serve/bundle.js +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.aglhome.d.ts +25 -0
- package/dist_ts/classes.aglhome.js +65 -0
- package/dist_ts/classes.authmodels.js +7 -12
- package/dist_ts/classes.authstore.js +2 -29
- package/dist_ts/classes.cli.js +93 -35
- package/dist_ts/classes.config.d.ts +2 -11
- package/dist_ts/classes.config.js +14 -80
- package/dist_ts/classes.controller.d.ts +4 -1
- package/dist_ts/classes.controller.js +64 -26
- package/dist_ts/classes.embeddeddb.js +6 -5
- package/dist_ts/classes.gitreversion.js +3 -2
- package/dist_ts/classes.upgradecoordinator.d.ts +6 -1
- package/dist_ts/classes.upgradecoordinator.js +588 -177
- package/dist_ts/classes.upgradetransaction.js +46 -12
- package/dist_ts/classes.uploadmanager.d.ts +12 -0
- package/dist_ts/classes.uploadmanager.js +204 -2
- package/dist_ts/constants.upgradeenvironment.d.ts +2 -0
- package/dist_ts/constants.upgradeenvironment.js +3 -0
- package/dist_ts/functions.controllerdataroot.d.ts +4 -5
- package/dist_ts/functions.controllerdataroot.js +4 -29
- package/dist_ts/functions.embeddeddb.d.ts +1 -1
- package/dist_ts/functions.embeddeddb.js +8 -3
- package/dist_ts/functions.runtimeenvironment.js +2 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.config.d.ts +5 -7
- package/dist_ts_migration/classes.documentmigrationrunner.js +3 -1
- package/dist_ts_migration/index.d.ts +2 -0
- package/dist_ts_migration/index.js +3 -1
- package/dist_ts_migration/v23_aglhome.d.ts +91 -0
- package/dist_ts_migration/v23_aglhome.js +1775 -0
- package/dist_ts_migration/v23_runtimeconfig.d.ts +11 -0
- package/dist_ts_migration/v23_runtimeconfig.js +87 -0
- package/dist_ts_migration/v2_controllerdataroot.d.ts +5 -0
- package/dist_ts_migration/v2_controllerdataroot.js +176 -15
- package/package.json +1 -1
- package/readme.md +116 -35
- package/readme.plan.md +24 -9
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.aglhome.ts +116 -0
- package/ts/classes.authmodels.ts +5 -13
- package/ts/classes.authstore.ts +1 -39
- package/ts/classes.cli.ts +92 -34
- package/ts/classes.config.ts +20 -117
- package/ts/classes.controller.ts +92 -26
- package/ts/classes.embeddeddb.ts +5 -4
- package/ts/classes.gitreversion.ts +3 -2
- package/ts/classes.upgradecoordinator.ts +668 -191
- package/ts/classes.upgradetransaction.ts +46 -11
- package/ts/classes.uploadmanager.ts +231 -1
- package/ts/constants.upgradeenvironment.ts +2 -0
- package/ts/functions.controllerdataroot.ts +11 -47
- package/ts/functions.embeddeddb.ts +13 -2
- package/ts/functions.runtimeenvironment.ts +1 -0
- package/ts/index.ts +1 -0
- package/ts/interfaces.config.ts +5 -7
- package/ts_migration/classes.documentmigrationrunner.ts +2 -0
- package/ts_migration/index.ts +2 -0
- package/ts_migration/v23_aglhome.ts +2101 -0
- package/ts_migration/v23_runtimeconfig.ts +112 -0
- package/ts_migration/v2_controllerdataroot.ts +174 -16
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,2101 @@
|
|
|
1
|
+
import * as plugins from '../ts/plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IAGLHomeOptions,
|
|
4
|
+
IAGLHomePaths,
|
|
5
|
+
} from '../ts/classes.aglhome.js';
|
|
6
|
+
import { resolveAGLHomePaths } from '../ts/classes.aglhome.js';
|
|
7
|
+
import {
|
|
8
|
+
readDatabaseConfig,
|
|
9
|
+
type IControllerDataDirectoryOptions,
|
|
10
|
+
} from '../ts/classes.config.js';
|
|
11
|
+
import {
|
|
12
|
+
listControllerDataWriterProcessesForCliPaths,
|
|
13
|
+
readControllerProcessIdentity,
|
|
14
|
+
} from '../ts/classes.processinspection.js';
|
|
15
|
+
import { embeddedDatabaseSocketPath, isSocketListening } from '../ts/functions.embeddeddb.js';
|
|
16
|
+
import type { IControllerDatabaseConfig } from '../ts/interfaces.config.js';
|
|
17
|
+
import {
|
|
18
|
+
ControllerDataRootMigrationRunner,
|
|
19
|
+
type IControllerDataRootMigrationResult,
|
|
20
|
+
type IControllerDataWriterProcessRecord,
|
|
21
|
+
} from './v2_controllerdataroot.js';
|
|
22
|
+
import { flexProviderCredentialStoreId } from './v16_flexprovidercredentials.js';
|
|
23
|
+
|
|
24
|
+
const migrationVersion = 23 as const;
|
|
25
|
+
const journalFileName = 'agl-home-v23.json';
|
|
26
|
+
const maximumJournalBytes = 256 * 1024;
|
|
27
|
+
const maximumLockBytes = 4 * 1024;
|
|
28
|
+
const maximumJournalTemporaryFiles = 16;
|
|
29
|
+
const maximumLockTemporaryFiles = 16;
|
|
30
|
+
const maximumRootEntries = 2_048;
|
|
31
|
+
const maximumCredentialStores = 512;
|
|
32
|
+
const maximumLegacyUploadRoots = 256;
|
|
33
|
+
const maximumLegacyUploadNodes = 8_192;
|
|
34
|
+
const maximumLegacyUploadDepth = 32;
|
|
35
|
+
const controllerLogPattern = /^controller-(?:[1-9][0-9]{0,4})\.log(?:\.old)?$/;
|
|
36
|
+
const upgradeLogPattern = /^upgrade-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}\.[0-9]{3}Z-[A-Za-z0-9_-]{10}\.log$/;
|
|
37
|
+
const credentialHashPattern = /^[a-f0-9]{64}$/;
|
|
38
|
+
const journalTemporaryPattern = /^agl-home-v23\.json\.[1-9][0-9]*-[a-f0-9]{16}\.tmp$/;
|
|
39
|
+
const lockTemporaryPattern = /\.tmp-([0-9]+)-([1-9][0-9]*)-([a-f0-9]{64})$/;
|
|
40
|
+
const legacyUploadRootPattern = /^harness-controller-uploads-[A-Za-z0-9]{6}$/;
|
|
41
|
+
const legacyUploadTombstonePattern = /^harness-controller-uploads-[A-Za-z0-9]{6}\.agl-v23-([a-f0-9]{64})\.removing$/;
|
|
42
|
+
const legacyUploadCleanupOperation = 'legacy-upload-roots';
|
|
43
|
+
const fixedOperationNames = new Set([
|
|
44
|
+
'browser-runtime-active',
|
|
45
|
+
'database-relocation-receipt',
|
|
46
|
+
'git-reversion',
|
|
47
|
+
'legacy-browser-runtime-config',
|
|
48
|
+
'legacy-browser-runtime-state',
|
|
49
|
+
'legacy-database-backup-v1',
|
|
50
|
+
legacyUploadCleanupOperation,
|
|
51
|
+
'legacy-v1-source-journal',
|
|
52
|
+
'legacy-v1-target-journal',
|
|
53
|
+
'legacy-v2-data-root-journal',
|
|
54
|
+
'legacy-v2-database-relocation-receipt',
|
|
55
|
+
'legacy-v2-target-marker',
|
|
56
|
+
'unused-local-database',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const isKnownOperationName = (valueArg: string): boolean => {
|
|
60
|
+
if (fixedOperationNames.has(valueArg)) return true;
|
|
61
|
+
if (/^credential:[a-f0-9]{64}$/.test(valueArg)) return true;
|
|
62
|
+
const logMatch = /^log:(data|config|state):(.+)$/.exec(valueArg);
|
|
63
|
+
return Boolean(logMatch && (
|
|
64
|
+
controllerLogPattern.test(logMatch[2]) || upgradeLogPattern.test(logMatch[2])
|
|
65
|
+
));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const legacyEmbeddedDatabaseSocketPath = (dataDirectoryArg: string): string => {
|
|
69
|
+
const directoryHash = plugins.crypto
|
|
70
|
+
.createHash('sha256')
|
|
71
|
+
.update(dataDirectoryArg)
|
|
72
|
+
.digest('hex')
|
|
73
|
+
.slice(0, 16);
|
|
74
|
+
return plugins.path.join(plugins.os.tmpdir(), `harness-controller-${directoryHash}.sock`);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface IAGLLegacyPaths {
|
|
78
|
+
development: boolean;
|
|
79
|
+
legacySourceEnabled: boolean;
|
|
80
|
+
activeDataRoot: string;
|
|
81
|
+
legacyConfigRoot?: string;
|
|
82
|
+
legacyStateRoot?: string;
|
|
83
|
+
v2JournalPath?: string;
|
|
84
|
+
v2LockPath?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const modulePackageRoot = plugins.path.resolve(
|
|
88
|
+
plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url)),
|
|
89
|
+
'..',
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const isDevelopmentCheckout = (packageRootArg: string): boolean => {
|
|
93
|
+
try {
|
|
94
|
+
const stats = plugins.fs.lstatSync(plugins.path.join(packageRootArg, '.git'));
|
|
95
|
+
return stats.isDirectory() || stats.isFile();
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const resolveAGLLegacyPaths = (
|
|
102
|
+
optionsArg: IAGLHomeOptions = {},
|
|
103
|
+
): IAGLLegacyPaths => {
|
|
104
|
+
const environment = optionsArg.environment ?? process.env;
|
|
105
|
+
const packageRoot = plugins.path.normalize(optionsArg.packageRoot ?? modulePackageRoot);
|
|
106
|
+
if (isDevelopmentCheckout(packageRoot)) {
|
|
107
|
+
const defaultRoot = plugins.path.join(packageRoot, '.nogit', 'agl');
|
|
108
|
+
const configuredRoot = environment.AGL_HOME?.trim();
|
|
109
|
+
const legacySourceEnabled = !configuredRoot
|
|
110
|
+
|| plugins.path.normalize(configuredRoot) === defaultRoot;
|
|
111
|
+
return {
|
|
112
|
+
development: true,
|
|
113
|
+
legacySourceEnabled,
|
|
114
|
+
activeDataRoot: plugins.path.join(packageRoot, '.nogit'),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const homeDirectory = plugins.path.normalize(optionsArg.homeDirectory ?? plugins.os.homedir());
|
|
118
|
+
const configuredConfigHome = environment.XDG_CONFIG_HOME?.trim();
|
|
119
|
+
const configHome = configuredConfigHome && plugins.path.isAbsolute(configuredConfigHome)
|
|
120
|
+
? plugins.path.normalize(configuredConfigHome)
|
|
121
|
+
: plugins.path.join(homeDirectory, '.config');
|
|
122
|
+
const configuredStateHome = environment.XDG_STATE_HOME?.trim();
|
|
123
|
+
const stateHome = configuredStateHome && plugins.path.isAbsolute(configuredStateHome)
|
|
124
|
+
? plugins.path.normalize(configuredStateHome)
|
|
125
|
+
: plugins.path.join(homeDirectory, '.local', 'state');
|
|
126
|
+
return {
|
|
127
|
+
development: false,
|
|
128
|
+
legacySourceEnabled: true,
|
|
129
|
+
activeDataRoot: plugins.path.join(configHome, 'hcon'),
|
|
130
|
+
legacyConfigRoot: plugins.path.join(configHome, 'harness-controller'),
|
|
131
|
+
legacyStateRoot: plugins.path.join(stateHome, 'harness-controller'),
|
|
132
|
+
v2JournalPath: plugins.path.join(configHome, '.hcon-controller-data-root-migration.json'),
|
|
133
|
+
v2LockPath: plugins.path.join(configHome, '.hcon-controller-data-root-migration.lock'),
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const readLegacyV2DatabaseConfig = (
|
|
138
|
+
optionsArg: IAGLHomeOptions = {},
|
|
139
|
+
): IControllerDatabaseConfig => {
|
|
140
|
+
const canonical = readDatabaseConfig(optionsArg);
|
|
141
|
+
const environment = optionsArg.environment ?? process.env;
|
|
142
|
+
if (canonical.mongoDbUrl !== undefined || environment.HARNESS_CONTROLLER_DB_DIR?.trim()) {
|
|
143
|
+
return canonical;
|
|
144
|
+
}
|
|
145
|
+
const legacy = resolveAGLLegacyPaths(optionsArg);
|
|
146
|
+
return {
|
|
147
|
+
mongoDbName: canonical.mongoDbName,
|
|
148
|
+
embeddedDataDirectory: plugins.path.join(legacy.activeDataRoot, 'smartdb'),
|
|
149
|
+
...(legacy.legacyStateRoot
|
|
150
|
+
? { legacyEmbeddedDataDirectory: plugins.path.join(legacy.legacyStateRoot, 'smartdb') }
|
|
151
|
+
: {}),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
interface IFilesystemIdentity {
|
|
156
|
+
device: string;
|
|
157
|
+
inode: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface ISourceIdentity extends IFilesystemIdentity {
|
|
161
|
+
path: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface ICredentialRelocationBinding {
|
|
165
|
+
controllerHash: string;
|
|
166
|
+
sourceDirectory: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface ILegacyUploadRootBinding extends IFilesystemIdentity {
|
|
170
|
+
path: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface IAGLHomeMigrationJournal {
|
|
174
|
+
version: typeof migrationVersion;
|
|
175
|
+
phase: 'target-staged' | 'target-committed';
|
|
176
|
+
mode: 'development' | 'installed';
|
|
177
|
+
root: string;
|
|
178
|
+
rootDevice: string;
|
|
179
|
+
rootInode: string;
|
|
180
|
+
nonce: string;
|
|
181
|
+
sources: ISourceIdentity[];
|
|
182
|
+
credentialRelocations: ICredentialRelocationBinding[];
|
|
183
|
+
legacyUploadRoots: ILegacyUploadRootBinding[];
|
|
184
|
+
completedOperations: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ILockOwner {
|
|
188
|
+
version: 1;
|
|
189
|
+
pid: number;
|
|
190
|
+
uid: number;
|
|
191
|
+
fingerprint: string;
|
|
192
|
+
nonce: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface IAGLHomeMigrationOptions {
|
|
196
|
+
paths: IAGLHomePaths;
|
|
197
|
+
legacyPaths: IAGLLegacyPaths;
|
|
198
|
+
databaseConfig: IControllerDatabaseConfig;
|
|
199
|
+
invokerPid: number;
|
|
200
|
+
listDataWriterProcesses: () => Promise<readonly IControllerDataWriterProcessRecord[]>;
|
|
201
|
+
isSocketListening?: (socketPathArg: string) => Promise<boolean>;
|
|
202
|
+
relocateStoppedDatabaseRoot?: (
|
|
203
|
+
inputArg: Readonly<plugins.smartdb.ILocalSmartDbStoppedStorageRootRelocationInput>,
|
|
204
|
+
signalArg?: AbortSignal,
|
|
205
|
+
) => Promise<plugins.smartdb.ILocalSmartDbStoppedStorageRootRelocationReceipt>;
|
|
206
|
+
relocateCredentialStore?: (
|
|
207
|
+
inputArg: {
|
|
208
|
+
controllerHash: string;
|
|
209
|
+
sourceDirectory: string;
|
|
210
|
+
destinationDirectory: string;
|
|
211
|
+
},
|
|
212
|
+
signalArg?: AbortSignal,
|
|
213
|
+
) => Promise<void>;
|
|
214
|
+
legacyUploadTempDirectory?: string;
|
|
215
|
+
signal?: AbortSignal;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface IAGLHomeMigrationPreflightOptions {
|
|
219
|
+
allowDataWriters?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface IAGLHomeMigrationResult {
|
|
223
|
+
directoryPath: string;
|
|
224
|
+
databaseConfig: IControllerDatabaseConfig;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const currentUid = (): number => {
|
|
228
|
+
if (typeof process.getuid !== 'function') {
|
|
229
|
+
throw new Error('AGL home migration requires a POSIX user identity.');
|
|
230
|
+
}
|
|
231
|
+
return process.getuid();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const isMissingError = (errorArg: unknown): boolean => (
|
|
235
|
+
(errorArg as NodeJS.ErrnoException).code === 'ENOENT'
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const identityFromStats = (statsArg: plugins.fs.BigIntStats): IFilesystemIdentity => ({
|
|
239
|
+
device: statsArg.dev.toString(10),
|
|
240
|
+
inode: statsArg.ino.toString(10),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const identitiesEqual = (
|
|
244
|
+
leftArg: IFilesystemIdentity,
|
|
245
|
+
rightArg: IFilesystemIdentity,
|
|
246
|
+
): boolean => leftArg.device === rightArg.device && leftArg.inode === rightArg.inode;
|
|
247
|
+
|
|
248
|
+
const lstatIfPresent = async (pathArg: string): Promise<plugins.fs.BigIntStats | undefined> => {
|
|
249
|
+
try {
|
|
250
|
+
return await plugins.fs.promises.lstat(pathArg, { bigint: true });
|
|
251
|
+
} catch (errorArg) {
|
|
252
|
+
if (isMissingError(errorArg)) return undefined;
|
|
253
|
+
throw errorArg;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const assertOwnedNode = (
|
|
258
|
+
pathArg: string,
|
|
259
|
+
statsArg: plugins.fs.BigIntStats,
|
|
260
|
+
kindArg: 'file' | 'directory',
|
|
261
|
+
privateRootArg = false,
|
|
262
|
+
): void => {
|
|
263
|
+
if (
|
|
264
|
+
statsArg.isSymbolicLink()
|
|
265
|
+
|| (kindArg === 'file' ? !statsArg.isFile() : !statsArg.isDirectory())
|
|
266
|
+
|| statsArg.uid !== BigInt(currentUid())
|
|
267
|
+
|| (kindArg === 'file' && statsArg.nlink !== 1n)
|
|
268
|
+
|| (privateRootArg && Number(statsArg.mode & 0o077n) !== 0)
|
|
269
|
+
) throw new Error(`AGL migration path is unsafe: ${pathArg}`);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const assertSafeAncestor = (pathArg: string, statsArg: plugins.fs.BigIntStats): void => {
|
|
273
|
+
const mode = Number(statsArg.mode & 0o7777n);
|
|
274
|
+
if (
|
|
275
|
+
statsArg.isSymbolicLink()
|
|
276
|
+
|| !statsArg.isDirectory()
|
|
277
|
+
|| ((mode & 0o022) !== 0 && (mode & 0o1000) === 0)
|
|
278
|
+
) throw new Error(`AGL home ancestor is unsafe: ${pathArg}`);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const readBoundedDirectory = async (pathArg: string): Promise<string[]> => {
|
|
282
|
+
const names = await plugins.fs.promises.readdir(pathArg);
|
|
283
|
+
if (names.length > maximumRootEntries) {
|
|
284
|
+
throw new Error(`AGL migration directory has too many entries: ${pathArg}`);
|
|
285
|
+
}
|
|
286
|
+
return names.sort();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const syncDirectory = async (pathArg: string): Promise<void> => {
|
|
290
|
+
const handle = await plugins.fs.promises.open(
|
|
291
|
+
pathArg,
|
|
292
|
+
plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_DIRECTORY,
|
|
293
|
+
);
|
|
294
|
+
try {
|
|
295
|
+
await handle.sync();
|
|
296
|
+
} finally {
|
|
297
|
+
await handle.close();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const assertSafeSourceTree = async (
|
|
302
|
+
rootArg: string,
|
|
303
|
+
destinationDeviceArg?: string,
|
|
304
|
+
): Promise<void> => {
|
|
305
|
+
const pending = [rootArg];
|
|
306
|
+
let inspected = 0;
|
|
307
|
+
while (pending.length > 0) {
|
|
308
|
+
const path = pending.pop()!;
|
|
309
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
310
|
+
if (
|
|
311
|
+
stats.isSymbolicLink()
|
|
312
|
+
|| (!stats.isDirectory() && !stats.isFile())
|
|
313
|
+
|| stats.uid !== BigInt(currentUid())
|
|
314
|
+
|| (stats.isFile() && stats.nlink !== 1n)
|
|
315
|
+
|| (destinationDeviceArg !== undefined
|
|
316
|
+
&& stats.dev.toString(10) !== destinationDeviceArg)
|
|
317
|
+
) {
|
|
318
|
+
throw new Error(`AGL migration source tree is unsafe: ${path}`);
|
|
319
|
+
}
|
|
320
|
+
inspected += 1;
|
|
321
|
+
if (inspected > 200_000) throw new Error('AGL migration source tree exceeds its node limit.');
|
|
322
|
+
if (!stats.isDirectory()) continue;
|
|
323
|
+
for (const name of await readBoundedDirectory(path)) {
|
|
324
|
+
pending.push(plugins.path.join(path, name));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const assertSafeLegacyUploadTree = async (
|
|
330
|
+
rootArg: string,
|
|
331
|
+
destinationDeviceArg: string,
|
|
332
|
+
): Promise<void> => {
|
|
333
|
+
const pending = [{ path: rootArg, depth: 0 }];
|
|
334
|
+
let inspected = 0;
|
|
335
|
+
while (pending.length > 0) {
|
|
336
|
+
const current = pending.pop()!;
|
|
337
|
+
const stats = await plugins.fs.promises.lstat(current.path, { bigint: true });
|
|
338
|
+
if (
|
|
339
|
+
stats.isSymbolicLink()
|
|
340
|
+
|| (!stats.isDirectory() && !stats.isFile())
|
|
341
|
+
|| stats.uid !== BigInt(currentUid())
|
|
342
|
+
|| stats.dev.toString(10) !== destinationDeviceArg
|
|
343
|
+
|| (stats.isFile() && stats.nlink !== 1n)
|
|
344
|
+
) throw new Error(`Legacy controller upload tree is unsafe: ${current.path}`);
|
|
345
|
+
inspected += 1;
|
|
346
|
+
if (inspected > maximumLegacyUploadNodes) {
|
|
347
|
+
throw new Error(`Legacy controller upload tree exceeds its node limit: ${rootArg}`);
|
|
348
|
+
}
|
|
349
|
+
if (!stats.isDirectory()) continue;
|
|
350
|
+
const names = await readBoundedDirectory(current.path);
|
|
351
|
+
if (names.length > 0 && current.depth >= maximumLegacyUploadDepth) {
|
|
352
|
+
throw new Error(`Legacy controller upload tree exceeds its depth limit: ${rootArg}`);
|
|
353
|
+
}
|
|
354
|
+
for (const name of names) {
|
|
355
|
+
pending.push({ path: plugins.path.join(current.path, name), depth: current.depth + 1 });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const nearestExistingDirectory = async (pathArg: string): Promise<{
|
|
361
|
+
path: string;
|
|
362
|
+
identity: IFilesystemIdentity;
|
|
363
|
+
}> => {
|
|
364
|
+
let candidate = pathArg;
|
|
365
|
+
while (true) {
|
|
366
|
+
const stats = await lstatIfPresent(candidate);
|
|
367
|
+
if (stats) {
|
|
368
|
+
assertSafeAncestor(candidate, stats);
|
|
369
|
+
const canonical = await plugins.fs.promises.realpath(candidate);
|
|
370
|
+
if (canonical !== candidate) {
|
|
371
|
+
throw new Error(`AGL home ancestor is not canonical: ${candidate}`);
|
|
372
|
+
}
|
|
373
|
+
return { path: candidate, identity: identityFromStats(stats) };
|
|
374
|
+
}
|
|
375
|
+
const parent = plugins.path.dirname(candidate);
|
|
376
|
+
if (parent === candidate) throw new Error('AGL home has no existing directory ancestor.');
|
|
377
|
+
candidate = parent;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const parseJournal = (valueArg: unknown): IAGLHomeMigrationJournal => {
|
|
382
|
+
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
|
383
|
+
throw new Error('AGL home migration journal is malformed.');
|
|
384
|
+
}
|
|
385
|
+
const value = valueArg as Record<string, unknown>;
|
|
386
|
+
const keys = Object.keys(value).sort();
|
|
387
|
+
const expected = [
|
|
388
|
+
'completedOperations',
|
|
389
|
+
'credentialRelocations',
|
|
390
|
+
'legacyUploadRoots',
|
|
391
|
+
'mode',
|
|
392
|
+
'nonce',
|
|
393
|
+
'phase',
|
|
394
|
+
'root',
|
|
395
|
+
'rootDevice',
|
|
396
|
+
'rootInode',
|
|
397
|
+
'sources',
|
|
398
|
+
'version',
|
|
399
|
+
].sort();
|
|
400
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
401
|
+
throw new Error('AGL home migration journal has unexpected fields.');
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
value.version !== migrationVersion
|
|
405
|
+
|| (value.phase !== 'target-staged' && value.phase !== 'target-committed')
|
|
406
|
+
|| (value.mode !== 'development' && value.mode !== 'installed')
|
|
407
|
+
|| typeof value.root !== 'string'
|
|
408
|
+
|| !plugins.path.isAbsolute(value.root)
|
|
409
|
+
|| plugins.path.normalize(value.root) !== value.root
|
|
410
|
+
|| typeof value.rootDevice !== 'string'
|
|
411
|
+
|| !/^[0-9]+$/.test(value.rootDevice)
|
|
412
|
+
|| typeof value.rootInode !== 'string'
|
|
413
|
+
|| !/^[0-9]+$/.test(value.rootInode)
|
|
414
|
+
|| typeof value.nonce !== 'string'
|
|
415
|
+
|| !/^[a-f0-9]{64}$/.test(value.nonce)
|
|
416
|
+
|| !Array.isArray(value.completedOperations)
|
|
417
|
+
|| value.completedOperations.length > 4_096
|
|
418
|
+
|| !value.completedOperations.every((entry) => (
|
|
419
|
+
typeof entry === 'string' && entry.length <= 512 && isKnownOperationName(entry)
|
|
420
|
+
))
|
|
421
|
+
|| new Set(value.completedOperations).size !== value.completedOperations.length
|
|
422
|
+
|| (value.completedOperations as string[]).some((entry, index, entries) => (
|
|
423
|
+
index > 0 && entries[index - 1] > entry
|
|
424
|
+
))
|
|
425
|
+
|| !Array.isArray(value.sources)
|
|
426
|
+
|| value.sources.length > 8
|
|
427
|
+
|| !Array.isArray(value.credentialRelocations)
|
|
428
|
+
|| value.credentialRelocations.length > maximumCredentialStores
|
|
429
|
+
|| !Array.isArray(value.legacyUploadRoots)
|
|
430
|
+
|| value.legacyUploadRoots.length > maximumLegacyUploadRoots
|
|
431
|
+
) throw new Error('AGL home migration journal is invalid.');
|
|
432
|
+
const sources = value.sources.map((entryArg): ISourceIdentity => {
|
|
433
|
+
if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) {
|
|
434
|
+
throw new Error('AGL home migration source binding is malformed.');
|
|
435
|
+
}
|
|
436
|
+
const entry = entryArg as Record<string, unknown>;
|
|
437
|
+
if (
|
|
438
|
+
Object.keys(entry).sort().join('\0') !== ['device', 'inode', 'path'].sort().join('\0')
|
|
439
|
+
|| typeof entry.path !== 'string'
|
|
440
|
+
|| !plugins.path.isAbsolute(entry.path)
|
|
441
|
+
|| plugins.path.normalize(entry.path) !== entry.path
|
|
442
|
+
|| typeof entry.device !== 'string'
|
|
443
|
+
|| !/^[0-9]+$/.test(entry.device)
|
|
444
|
+
|| typeof entry.inode !== 'string'
|
|
445
|
+
|| !/^[0-9]+$/.test(entry.inode)
|
|
446
|
+
) throw new Error('AGL home migration source binding is invalid.');
|
|
447
|
+
return { path: entry.path, device: entry.device, inode: entry.inode };
|
|
448
|
+
});
|
|
449
|
+
const credentialRelocations = value.credentialRelocations.map(
|
|
450
|
+
(entryArg, index, entries): ICredentialRelocationBinding => {
|
|
451
|
+
if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) {
|
|
452
|
+
throw new Error('AGL credential relocation binding is malformed.');
|
|
453
|
+
}
|
|
454
|
+
const entry = entryArg as Record<string, unknown>;
|
|
455
|
+
if (
|
|
456
|
+
Object.keys(entry).sort().join('\0')
|
|
457
|
+
!== ['controllerHash', 'sourceDirectory'].sort().join('\0')
|
|
458
|
+
|| typeof entry.controllerHash !== 'string'
|
|
459
|
+
|| !credentialHashPattern.test(entry.controllerHash)
|
|
460
|
+
|| typeof entry.sourceDirectory !== 'string'
|
|
461
|
+
|| !plugins.path.isAbsolute(entry.sourceDirectory)
|
|
462
|
+
|| plugins.path.normalize(entry.sourceDirectory) !== entry.sourceDirectory
|
|
463
|
+
|| (index > 0
|
|
464
|
+
&& ((entries[index - 1] as Record<string, unknown>).controllerHash as string)
|
|
465
|
+
>= entry.controllerHash)
|
|
466
|
+
) throw new Error('AGL credential relocation binding is invalid.');
|
|
467
|
+
return {
|
|
468
|
+
controllerHash: entry.controllerHash,
|
|
469
|
+
sourceDirectory: entry.sourceDirectory,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
const legacyUploadRoots = value.legacyUploadRoots.map(
|
|
474
|
+
(entryArg, index, entries): ILegacyUploadRootBinding => {
|
|
475
|
+
if (!entryArg || typeof entryArg !== 'object' || Array.isArray(entryArg)) {
|
|
476
|
+
throw new Error('Legacy controller upload root binding is malformed.');
|
|
477
|
+
}
|
|
478
|
+
const entry = entryArg as Record<string, unknown>;
|
|
479
|
+
if (
|
|
480
|
+
Object.keys(entry).sort().join('\0') !== ['device', 'inode', 'path'].sort().join('\0')
|
|
481
|
+
|| typeof entry.path !== 'string'
|
|
482
|
+
|| !plugins.path.isAbsolute(entry.path)
|
|
483
|
+
|| plugins.path.normalize(entry.path) !== entry.path
|
|
484
|
+
|| !legacyUploadRootPattern.test(plugins.path.basename(entry.path))
|
|
485
|
+
|| typeof entry.device !== 'string'
|
|
486
|
+
|| !/^[0-9]+$/.test(entry.device)
|
|
487
|
+
|| typeof entry.inode !== 'string'
|
|
488
|
+
|| !/^[0-9]+$/.test(entry.inode)
|
|
489
|
+
|| (index > 0
|
|
490
|
+
&& ((entries[index - 1] as Record<string, unknown>).path as string) >= entry.path)
|
|
491
|
+
) throw new Error('Legacy controller upload root binding is invalid.');
|
|
492
|
+
return {
|
|
493
|
+
path: entry.path,
|
|
494
|
+
device: entry.device,
|
|
495
|
+
inode: entry.inode,
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
return {
|
|
500
|
+
version: migrationVersion,
|
|
501
|
+
phase: value.phase,
|
|
502
|
+
mode: value.mode,
|
|
503
|
+
root: value.root,
|
|
504
|
+
rootDevice: value.rootDevice,
|
|
505
|
+
rootInode: value.rootInode,
|
|
506
|
+
nonce: value.nonce,
|
|
507
|
+
sources,
|
|
508
|
+
credentialRelocations,
|
|
509
|
+
legacyUploadRoots,
|
|
510
|
+
completedOperations: [...value.completedOperations],
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const readPrivateJournal = async (
|
|
515
|
+
pathArg: string,
|
|
516
|
+
): Promise<IAGLHomeMigrationJournal | undefined> => {
|
|
517
|
+
let handle: plugins.fs.promises.FileHandle;
|
|
518
|
+
try {
|
|
519
|
+
handle = await plugins.fs.promises.open(
|
|
520
|
+
pathArg,
|
|
521
|
+
plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW,
|
|
522
|
+
);
|
|
523
|
+
} catch (errorArg) {
|
|
524
|
+
if (isMissingError(errorArg)) return undefined;
|
|
525
|
+
throw errorArg;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const stats = await handle.stat({ bigint: true });
|
|
529
|
+
if (
|
|
530
|
+
!stats.isFile()
|
|
531
|
+
|| stats.uid !== BigInt(currentUid())
|
|
532
|
+
|| stats.nlink !== 1n
|
|
533
|
+
|| Number(stats.mode & 0o777n) !== 0o600
|
|
534
|
+
|| stats.size < 2n
|
|
535
|
+
|| stats.size > BigInt(maximumJournalBytes)
|
|
536
|
+
) throw new Error('AGL home migration journal is not a private bounded file.');
|
|
537
|
+
return parseJournal(JSON.parse(await handle.readFile('utf8')) as unknown);
|
|
538
|
+
} finally {
|
|
539
|
+
await handle.close();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const readPrivateLock = async (
|
|
544
|
+
pathArg: string,
|
|
545
|
+
allowHardlinkArg = false,
|
|
546
|
+
): Promise<{ identity: IFilesystemIdentity; owner: ILockOwner } | undefined> => {
|
|
547
|
+
let handle: plugins.fs.promises.FileHandle;
|
|
548
|
+
try {
|
|
549
|
+
handle = await plugins.fs.promises.open(
|
|
550
|
+
pathArg,
|
|
551
|
+
plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW,
|
|
552
|
+
);
|
|
553
|
+
} catch (errorArg) {
|
|
554
|
+
if (isMissingError(errorArg)) return undefined;
|
|
555
|
+
throw errorArg;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const stats = await handle.stat({ bigint: true });
|
|
559
|
+
if (
|
|
560
|
+
!stats.isFile()
|
|
561
|
+
|| stats.uid !== BigInt(currentUid())
|
|
562
|
+
|| (stats.nlink !== 1n && !(allowHardlinkArg && stats.nlink === 2n))
|
|
563
|
+
|| Number(stats.mode & 0o777n) !== 0o600
|
|
564
|
+
|| stats.size < 2n
|
|
565
|
+
|| stats.size > BigInt(maximumLockBytes)
|
|
566
|
+
) throw new Error('AGL home migration lock is not a private bounded file.');
|
|
567
|
+
const raw = JSON.parse(await handle.readFile('utf8')) as Record<string, unknown>;
|
|
568
|
+
if (
|
|
569
|
+
Object.keys(raw).sort().join('\0')
|
|
570
|
+
!== ['fingerprint', 'nonce', 'pid', 'uid', 'version'].sort().join('\0')
|
|
571
|
+
|| raw.version !== 1
|
|
572
|
+
|| raw.uid !== currentUid()
|
|
573
|
+
|| !Number.isSafeInteger(raw.pid)
|
|
574
|
+
|| (raw.pid as number) < 2
|
|
575
|
+
|| typeof raw.fingerprint !== 'string'
|
|
576
|
+
|| raw.fingerprint.length < 1
|
|
577
|
+
|| raw.fingerprint.length > 512
|
|
578
|
+
|| typeof raw.nonce !== 'string'
|
|
579
|
+
|| !/^[a-f0-9]{64}$/.test(raw.nonce)
|
|
580
|
+
) throw new Error('AGL home migration lock is malformed.');
|
|
581
|
+
return {
|
|
582
|
+
identity: identityFromStats(stats),
|
|
583
|
+
owner: raw as unknown as ILockOwner,
|
|
584
|
+
};
|
|
585
|
+
} finally {
|
|
586
|
+
await handle.close();
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const closeKernelStore = async (
|
|
591
|
+
kernelStoreArg: plugins.smartsecret.SmartSecretKernelStore,
|
|
592
|
+
): Promise<void> => {
|
|
593
|
+
const errors: unknown[] = [];
|
|
594
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
595
|
+
try {
|
|
596
|
+
await kernelStoreArg.close();
|
|
597
|
+
return;
|
|
598
|
+
} catch (errorArg) {
|
|
599
|
+
errors.push(errorArg);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
throw new AggregateError(errors, 'AGL credential kernel-store closure could not be confirmed.');
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const retainedCredentialKernelStores = new Set<plugins.smartsecret.SmartSecretKernelStore>();
|
|
606
|
+
|
|
607
|
+
const drainRetainedCredentialKernelStores = async (): Promise<void> => {
|
|
608
|
+
for (const kernelStore of retainedCredentialKernelStores) {
|
|
609
|
+
await closeKernelStore(kernelStore);
|
|
610
|
+
retainedCredentialKernelStores.delete(kernelStore);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const relocateCredentialStore = async (
|
|
615
|
+
inputArg: {
|
|
616
|
+
controllerHash: string;
|
|
617
|
+
sourceDirectory: string;
|
|
618
|
+
destinationDirectory: string;
|
|
619
|
+
},
|
|
620
|
+
signalArg?: AbortSignal,
|
|
621
|
+
): Promise<void> => {
|
|
622
|
+
signalArg?.throwIfAborted();
|
|
623
|
+
await drainRetainedCredentialKernelStores();
|
|
624
|
+
let kernelStore: plugins.smartsecret.SmartSecretKernelStore | undefined;
|
|
625
|
+
let sealedStore: plugins.smartsecret.SmartSecretSealedFileStore | undefined;
|
|
626
|
+
let operationError: unknown;
|
|
627
|
+
try {
|
|
628
|
+
kernelStore = await plugins.smartsecret.SmartSecretKernelStore.create({
|
|
629
|
+
service: `modelprofile.flexharness.${inputArg.controllerHash.slice(0, 32)}`,
|
|
630
|
+
});
|
|
631
|
+
retainedCredentialKernelStores.add(kernelStore);
|
|
632
|
+
sealedStore = await plugins.smartsecret.SmartSecretSealedFileStore.relocate({
|
|
633
|
+
kernelStore,
|
|
634
|
+
storeId: flexProviderCredentialStoreId,
|
|
635
|
+
sourceDirectoryPath: inputArg.sourceDirectory,
|
|
636
|
+
destinationDirectoryPath: inputArg.destinationDirectory,
|
|
637
|
+
});
|
|
638
|
+
await sealedStore.close();
|
|
639
|
+
sealedStore = undefined;
|
|
640
|
+
} catch (errorArg) {
|
|
641
|
+
operationError = errorArg;
|
|
642
|
+
}
|
|
643
|
+
if (sealedStore) {
|
|
644
|
+
try {
|
|
645
|
+
await sealedStore.close();
|
|
646
|
+
} catch (errorArg) {
|
|
647
|
+
operationError = operationError
|
|
648
|
+
? new AggregateError([operationError, errorArg], 'AGL credential relocation cleanup failed.')
|
|
649
|
+
: errorArg;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (kernelStore) {
|
|
653
|
+
try {
|
|
654
|
+
await closeKernelStore(kernelStore);
|
|
655
|
+
retainedCredentialKernelStores.delete(kernelStore);
|
|
656
|
+
} catch (errorArg) {
|
|
657
|
+
operationError = operationError
|
|
658
|
+
? new AggregateError([operationError, errorArg], 'AGL credential relocation cleanup failed.')
|
|
659
|
+
: errorArg;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (operationError) throw operationError;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
export class AGLHomeMigrationRunner {
|
|
666
|
+
private readonly journalPath: string;
|
|
667
|
+
private readonly lockPath: string;
|
|
668
|
+
private readonly socketCheck: (socketPathArg: string) => Promise<boolean>;
|
|
669
|
+
private readonly relocateDatabase: NonNullable<
|
|
670
|
+
IAGLHomeMigrationOptions['relocateStoppedDatabaseRoot']
|
|
671
|
+
>;
|
|
672
|
+
private readonly relocateCredentials: NonNullable<
|
|
673
|
+
IAGLHomeMigrationOptions['relocateCredentialStore']
|
|
674
|
+
>;
|
|
675
|
+
private readonly legacyUploadTempDirectory: string;
|
|
676
|
+
|
|
677
|
+
constructor(private readonly options: IAGLHomeMigrationOptions) {
|
|
678
|
+
this.journalPath = plugins.path.join(options.paths.migration, journalFileName);
|
|
679
|
+
this.lockPath = plugins.path.join(
|
|
680
|
+
plugins.path.dirname(options.paths.root),
|
|
681
|
+
`.${plugins.path.basename(options.paths.root)}-home-migration.lock`,
|
|
682
|
+
);
|
|
683
|
+
this.socketCheck = options.isSocketListening ?? isSocketListening;
|
|
684
|
+
this.relocateDatabase = options.relocateStoppedDatabaseRoot
|
|
685
|
+
?? (async (inputArg, signalArg) => plugins.smartdb.LocalSmartDb.relocateStoppedStorageRoot(
|
|
686
|
+
{ ...inputArg },
|
|
687
|
+
signalArg ? { signal: signalArg } : undefined,
|
|
688
|
+
));
|
|
689
|
+
this.relocateCredentials = options.relocateCredentialStore ?? relocateCredentialStore;
|
|
690
|
+
const configuredLegacyUploadTempDirectory = plugins.path.normalize(
|
|
691
|
+
options.legacyUploadTempDirectory ?? plugins.os.tmpdir(),
|
|
692
|
+
);
|
|
693
|
+
if (
|
|
694
|
+
!plugins.path.isAbsolute(configuredLegacyUploadTempDirectory)
|
|
695
|
+
|| plugins.path.parse(configuredLegacyUploadTempDirectory).root
|
|
696
|
+
=== configuredLegacyUploadTempDirectory
|
|
697
|
+
|| configuredLegacyUploadTempDirectory.includes('\0')
|
|
698
|
+
|| Buffer.byteLength(configuredLegacyUploadTempDirectory, 'utf8') > 4096
|
|
699
|
+
) throw new Error('The legacy controller upload temp directory is invalid.');
|
|
700
|
+
this.legacyUploadTempDirectory = plugins.fs.realpathSync(configuredLegacyUploadTempDirectory);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
public async isCommitted(): Promise<boolean> {
|
|
704
|
+
const journal = await readPrivateJournal(this.journalPath);
|
|
705
|
+
if (!journal) return false;
|
|
706
|
+
this.assertJournalBinding(journal);
|
|
707
|
+
await this.assertJournalRootIdentity(journal);
|
|
708
|
+
return journal.phase === 'target-committed';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
public async hasStarted(): Promise<boolean> {
|
|
712
|
+
const journal = await readPrivateJournal(this.journalPath);
|
|
713
|
+
if (!journal) return false;
|
|
714
|
+
this.assertJournalBinding(journal);
|
|
715
|
+
await this.assertJournalRootIdentity(journal);
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Performs no writes and is safe to invoke before logs or admission state exist. */
|
|
720
|
+
public async preflight(optionsArg: IAGLHomeMigrationPreflightOptions = {}): Promise<void> {
|
|
721
|
+
this.options.signal?.throwIfAborted();
|
|
722
|
+
if (this.options.invokerPid !== process.pid) {
|
|
723
|
+
throw new Error('AGL home migration invoker PID is not the current process.');
|
|
724
|
+
}
|
|
725
|
+
if (plugins.path.dirname(this.options.paths.root) === this.options.paths.root) {
|
|
726
|
+
throw new Error('AGL_HOME cannot be a filesystem root.');
|
|
727
|
+
}
|
|
728
|
+
const destinationParent = await lstatIfPresent(plugins.path.dirname(this.options.paths.root));
|
|
729
|
+
if (!destinationParent) {
|
|
730
|
+
throw new Error('The direct parent of AGL_HOME must already exist.');
|
|
731
|
+
}
|
|
732
|
+
assertSafeAncestor(plugins.path.dirname(this.options.paths.root), destinationParent);
|
|
733
|
+
const destinationAncestor = await nearestExistingDirectory(this.options.paths.root);
|
|
734
|
+
const rootStats = await lstatIfPresent(this.options.paths.root);
|
|
735
|
+
if (rootStats) assertOwnedNode(this.options.paths.root, rootStats, 'directory', true);
|
|
736
|
+
const journal = await readPrivateJournal(this.journalPath);
|
|
737
|
+
if (journal) {
|
|
738
|
+
this.assertJournalBinding(journal);
|
|
739
|
+
await this.assertJournalRootIdentity(journal, rootStats);
|
|
740
|
+
for (const source of journal.sources) {
|
|
741
|
+
const stats = await lstatIfPresent(source.path);
|
|
742
|
+
if (stats && !identitiesEqual(identityFromStats(stats), source)) {
|
|
743
|
+
throw new Error(`AGL migration source identity changed: ${source.path}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (journal.completedOperations.includes(legacyUploadCleanupOperation)) {
|
|
747
|
+
await this.assertNoLegacyUploadRoots(journal);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (rootStats && journal?.phase !== 'target-committed') {
|
|
751
|
+
await assertSafeSourceTree(this.options.paths.root, destinationAncestor.identity.device);
|
|
752
|
+
}
|
|
753
|
+
await this.inspectTargetInventory(journal !== undefined);
|
|
754
|
+
const sourceNodes = await this.inspectSourceInventories();
|
|
755
|
+
if (journal?.phase === 'target-committed' && sourceNodes.length > 0) {
|
|
756
|
+
throw new Error('Legacy AGL state appeared after the target became authoritative.');
|
|
757
|
+
}
|
|
758
|
+
for (const source of sourceNodes) {
|
|
759
|
+
if (await plugins.fs.promises.realpath(source.path) !== source.path) {
|
|
760
|
+
throw new Error(`AGL migration source path is not canonical: ${source.path}`);
|
|
761
|
+
}
|
|
762
|
+
if (source.identity.device !== destinationAncestor.identity.device) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`AGL home migration cannot cross filesystems: ${source.path} -> ${this.options.paths.root}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
await assertSafeSourceTree(source.path, destinationAncestor.identity.device);
|
|
768
|
+
}
|
|
769
|
+
await this.assertGitStateRelocatable();
|
|
770
|
+
if (!optionsArg.allowDataWriters) await this.assertNoConflictingWriters();
|
|
771
|
+
if (this.usesDefaultEmbeddedDatabase()) {
|
|
772
|
+
const directories = [this.options.paths.database];
|
|
773
|
+
if (this.options.legacyPaths.legacySourceEnabled) {
|
|
774
|
+
directories.unshift(plugins.path.join(this.options.legacyPaths.activeDataRoot, 'smartdb'));
|
|
775
|
+
}
|
|
776
|
+
if (this.options.legacyPaths.legacyStateRoot) {
|
|
777
|
+
directories.unshift(plugins.path.join(this.options.legacyPaths.legacyStateRoot, 'smartdb'));
|
|
778
|
+
}
|
|
779
|
+
for (const directory of directories) {
|
|
780
|
+
if (
|
|
781
|
+
journal?.phase === 'target-committed'
|
|
782
|
+
&& directory === this.options.paths.database
|
|
783
|
+
) continue;
|
|
784
|
+
const socketPath = directory === this.options.paths.database
|
|
785
|
+
? embeddedDatabaseSocketPath(directory, this.options.paths.sockets)
|
|
786
|
+
: legacyEmbeddedDatabaseSocketPath(directory);
|
|
787
|
+
if (await this.socketCheck(socketPath)) {
|
|
788
|
+
throw new Error(`AGL database migration is blocked by an active socket: ${directory}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
public async run(): Promise<IAGLHomeMigrationResult> {
|
|
795
|
+
await this.preflight();
|
|
796
|
+
const lock = await this.acquireLock();
|
|
797
|
+
let operationError: unknown;
|
|
798
|
+
let result: IAGLHomeMigrationResult | undefined;
|
|
799
|
+
try {
|
|
800
|
+
await this.cleanupJournalTemporaryFiles();
|
|
801
|
+
await this.preflight();
|
|
802
|
+
result = await this.runLocked();
|
|
803
|
+
} catch (errorArg) {
|
|
804
|
+
operationError = errorArg;
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
await this.releaseLock(lock);
|
|
808
|
+
} catch (errorArg) {
|
|
809
|
+
operationError = operationError
|
|
810
|
+
? new AggregateError([operationError, errorArg], 'AGL home migration and lock cleanup failed.')
|
|
811
|
+
: errorArg;
|
|
812
|
+
}
|
|
813
|
+
if (operationError) throw operationError;
|
|
814
|
+
return result!;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private usesDefaultEmbeddedDatabase(): boolean {
|
|
818
|
+
return this.options.databaseConfig.mongoDbUrl === undefined
|
|
819
|
+
&& this.options.databaseConfig.embeddedDataDirectory === this.options.paths.database;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private isConfiguredDatabaseSource(pathArg: string): boolean {
|
|
823
|
+
return this.options.databaseConfig.mongoDbUrl === undefined
|
|
824
|
+
&& this.options.databaseConfig.embeddedDataDirectory !== undefined
|
|
825
|
+
&& plugins.path.normalize(this.options.databaseConfig.embeddedDataDirectory)
|
|
826
|
+
!== plugins.path.normalize(this.options.paths.database)
|
|
827
|
+
&& plugins.path.normalize(this.options.databaseConfig.embeddedDataDirectory)
|
|
828
|
+
=== plugins.path.normalize(pathArg);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private assertJournalBinding(journalArg: IAGLHomeMigrationJournal): void {
|
|
832
|
+
if (
|
|
833
|
+
journalArg.root !== this.options.paths.root
|
|
834
|
+
|| journalArg.mode !== (this.options.legacyPaths.development ? 'development' : 'installed')
|
|
835
|
+
) throw new Error('AGL home migration journal belongs to another home or runtime mode.');
|
|
836
|
+
const sourceRoots = new Set([
|
|
837
|
+
...(this.options.legacyPaths.legacySourceEnabled
|
|
838
|
+
? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')]
|
|
839
|
+
: []),
|
|
840
|
+
...(this.options.legacyPaths.legacyConfigRoot
|
|
841
|
+
? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')]
|
|
842
|
+
: []),
|
|
843
|
+
]);
|
|
844
|
+
for (const binding of journalArg.credentialRelocations) {
|
|
845
|
+
if (!sourceRoots.has(plugins.path.dirname(binding.sourceDirectory))) {
|
|
846
|
+
throw new Error('AGL credential relocation binding belongs to another source root.');
|
|
847
|
+
}
|
|
848
|
+
if (plugins.path.basename(binding.sourceDirectory) !== binding.controllerHash) {
|
|
849
|
+
throw new Error('AGL credential relocation binding does not match its controller hash.');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
for (const binding of journalArg.legacyUploadRoots) {
|
|
853
|
+
if (
|
|
854
|
+
plugins.path.dirname(binding.path) !== this.legacyUploadTempDirectory
|
|
855
|
+
|| !legacyUploadRootPattern.test(plugins.path.basename(binding.path))
|
|
856
|
+
) throw new Error('Legacy controller upload root binding belongs to another temp directory.');
|
|
857
|
+
}
|
|
858
|
+
const boundHashes = new Set(
|
|
859
|
+
journalArg.credentialRelocations.map((binding) => binding.controllerHash),
|
|
860
|
+
);
|
|
861
|
+
for (const operation of journalArg.completedOperations) {
|
|
862
|
+
if (operation.startsWith('credential:') && !boundHashes.has(operation.slice('credential:'.length))) {
|
|
863
|
+
throw new Error('Completed AGL credential relocation has no source binding.');
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private async assertJournalRootIdentity(
|
|
869
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
870
|
+
statsArg?: plugins.fs.BigIntStats,
|
|
871
|
+
): Promise<void> {
|
|
872
|
+
const stats = statsArg ?? await lstatIfPresent(this.options.paths.root);
|
|
873
|
+
if (
|
|
874
|
+
!stats
|
|
875
|
+
|| !stats.isDirectory()
|
|
876
|
+
|| !identitiesEqual(identityFromStats(stats), {
|
|
877
|
+
device: journalArg.rootDevice,
|
|
878
|
+
inode: journalArg.rootInode,
|
|
879
|
+
})
|
|
880
|
+
) throw new Error('AGL home root identity changed after migration staging.');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private async inspectTargetInventory(hasJournalArg: boolean): Promise<void> {
|
|
884
|
+
const root = await lstatIfPresent(this.options.paths.root);
|
|
885
|
+
if (!root) return;
|
|
886
|
+
const allowed = new Set([
|
|
887
|
+
'database',
|
|
888
|
+
'credentials',
|
|
889
|
+
'git-reversion',
|
|
890
|
+
'logs',
|
|
891
|
+
'upgrade',
|
|
892
|
+
'cache',
|
|
893
|
+
'runtime',
|
|
894
|
+
'migration',
|
|
895
|
+
]);
|
|
896
|
+
for (const name of await readBoundedDirectory(this.options.paths.root)) {
|
|
897
|
+
if (!allowed.has(name)) throw new Error(`Unexpected entry in AGL_HOME: ${name}`);
|
|
898
|
+
const path = plugins.path.join(this.options.paths.root, name);
|
|
899
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
900
|
+
assertOwnedNode(path, stats, 'directory', true);
|
|
901
|
+
if (!hasJournalArg && name !== 'upgrade' && name !== 'migration') {
|
|
902
|
+
throw new Error(`Unjournaled AGL_HOME state is ambiguous: ${path}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const migrationStats = await lstatIfPresent(this.options.paths.migration);
|
|
906
|
+
if (migrationStats) {
|
|
907
|
+
const migrationNames = await readBoundedDirectory(this.options.paths.migration);
|
|
908
|
+
const allowedNames = hasJournalArg
|
|
909
|
+
? new Set([
|
|
910
|
+
journalFileName,
|
|
911
|
+
'active-database-relocation-receipt.json',
|
|
912
|
+
'legacy-unused-local-database',
|
|
913
|
+
'legacy-database-backup-v1',
|
|
914
|
+
'legacy-v2-target-marker.json',
|
|
915
|
+
'legacy-v1-target-journal.json',
|
|
916
|
+
'legacy-v1-source-journal.json',
|
|
917
|
+
'legacy-v2-database-relocation-receipt.json',
|
|
918
|
+
'legacy-browser-runtime-config',
|
|
919
|
+
'legacy-browser-runtime-state',
|
|
920
|
+
'legacy-v2-data-root-journal.json',
|
|
921
|
+
])
|
|
922
|
+
: new Set<string>();
|
|
923
|
+
const journalTemporaryNames = migrationNames.filter((name) => journalTemporaryPattern.test(name));
|
|
924
|
+
if (journalTemporaryNames.length > maximumJournalTemporaryFiles) {
|
|
925
|
+
throw new Error('AGL migration directory contains too many journal artifacts.');
|
|
926
|
+
}
|
|
927
|
+
if (
|
|
928
|
+
(hasJournalArg && !migrationNames.includes(journalFileName))
|
|
929
|
+
|| migrationNames.some((name) => (
|
|
930
|
+
!allowedNames.has(name) && !journalTemporaryPattern.test(name)
|
|
931
|
+
))
|
|
932
|
+
) throw new Error('AGL migration directory inventory is invalid.');
|
|
933
|
+
for (const name of journalTemporaryNames) {
|
|
934
|
+
const path = plugins.path.join(this.options.paths.migration, name);
|
|
935
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
936
|
+
if (
|
|
937
|
+
!stats.isFile()
|
|
938
|
+
|| stats.isSymbolicLink()
|
|
939
|
+
|| stats.uid !== BigInt(currentUid())
|
|
940
|
+
|| stats.nlink !== 1n
|
|
941
|
+
|| Number(stats.mode & 0o777n) !== 0o600
|
|
942
|
+
|| stats.size > BigInt(maximumJournalBytes)
|
|
943
|
+
) throw new Error(`AGL journal artifact is unsafe: ${path}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private async inspectSourceInventories(): Promise<Array<{
|
|
949
|
+
path: string;
|
|
950
|
+
identity: IFilesystemIdentity;
|
|
951
|
+
}>> {
|
|
952
|
+
const nodes: Array<{ path: string; identity: IFilesystemIdentity }> = [];
|
|
953
|
+
const inspectRoot = async (
|
|
954
|
+
rootArg: string | undefined,
|
|
955
|
+
allowedArg: ReadonlySet<string>,
|
|
956
|
+
dedicatedArg: boolean,
|
|
957
|
+
): Promise<void> => {
|
|
958
|
+
if (!rootArg) return;
|
|
959
|
+
const root = await lstatIfPresent(rootArg);
|
|
960
|
+
if (!root) return;
|
|
961
|
+
assertOwnedNode(rootArg, root, 'directory', dedicatedArg);
|
|
962
|
+
const names = await readBoundedDirectory(rootArg);
|
|
963
|
+
for (const name of names) {
|
|
964
|
+
if (!allowedArg.has(name)) {
|
|
965
|
+
if (dedicatedArg) throw new Error(`Unknown AGL legacy entry: ${plugins.path.join(rootArg, name)}`);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const path = plugins.path.join(rootArg, name);
|
|
969
|
+
if (this.isConfiguredDatabaseSource(path)) continue;
|
|
970
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
971
|
+
if (stats.isSymbolicLink() || stats.uid !== BigInt(currentUid())) {
|
|
972
|
+
throw new Error(`AGL legacy entry is unsafe: ${path}`);
|
|
973
|
+
}
|
|
974
|
+
nodes.push({ path, identity: identityFromStats(stats) });
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
const dataAllowed = new Set([
|
|
978
|
+
'.hcon-controller-data-root-marker.json',
|
|
979
|
+
'.smartdb-location-migration.json',
|
|
980
|
+
'smartdb',
|
|
981
|
+
'flex-provider-credentials',
|
|
982
|
+
'git-reversion',
|
|
983
|
+
'browser-runtime',
|
|
984
|
+
]);
|
|
985
|
+
if (this.options.legacyPaths.development) {
|
|
986
|
+
for (const name of await this.listLegacyLogNames(this.options.legacyPaths.activeDataRoot)) {
|
|
987
|
+
dataAllowed.add(name);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (this.options.legacyPaths.legacySourceEnabled) {
|
|
991
|
+
await inspectRoot(
|
|
992
|
+
this.options.legacyPaths.activeDataRoot,
|
|
993
|
+
dataAllowed,
|
|
994
|
+
!this.options.legacyPaths.development,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
const configAllowed = new Set([
|
|
998
|
+
'.smartdb-location-migration.json',
|
|
999
|
+
'smartdb',
|
|
1000
|
+
'flex-provider-credentials',
|
|
1001
|
+
'browser-runtime',
|
|
1002
|
+
]);
|
|
1003
|
+
for (const name of await this.listLegacyLogNames(this.options.legacyPaths.legacyConfigRoot)) {
|
|
1004
|
+
configAllowed.add(name);
|
|
1005
|
+
}
|
|
1006
|
+
await inspectRoot(this.options.legacyPaths.legacyConfigRoot, configAllowed, true);
|
|
1007
|
+
const stateAllowed = new Set(['smartdb', 'browser-runtime']);
|
|
1008
|
+
for (const name of await this.listLegacyLogNames(this.options.legacyPaths.legacyStateRoot)) {
|
|
1009
|
+
stateAllowed.add(name);
|
|
1010
|
+
}
|
|
1011
|
+
await inspectRoot(this.options.legacyPaths.legacyStateRoot, stateAllowed, true);
|
|
1012
|
+
for (const path of [this.options.legacyPaths.v2JournalPath]) {
|
|
1013
|
+
if (!path) continue;
|
|
1014
|
+
const stats = await lstatIfPresent(path);
|
|
1015
|
+
if (!stats) continue;
|
|
1016
|
+
assertOwnedNode(path, stats, 'file', true);
|
|
1017
|
+
nodes.push({ path, identity: identityFromStats(stats) });
|
|
1018
|
+
}
|
|
1019
|
+
if (this.options.legacyPaths.v2LockPath) {
|
|
1020
|
+
const lock = await lstatIfPresent(this.options.legacyPaths.v2LockPath);
|
|
1021
|
+
if (lock) throw new Error('The legacy controller data-root migration lock is still present.');
|
|
1022
|
+
const parent = plugins.path.dirname(this.options.legacyPaths.v2LockPath);
|
|
1023
|
+
const base = plugins.path.basename(this.options.legacyPaths.v2LockPath);
|
|
1024
|
+
for (const name of await readBoundedDirectory(parent)) {
|
|
1025
|
+
if (name.startsWith(`${base}.`)) {
|
|
1026
|
+
throw new Error(`A legacy controller data-root migration lock artifact remains: ${name}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return nodes;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private async listLegacyLogNames(rootArg: string | undefined): Promise<string[]> {
|
|
1034
|
+
if (!rootArg || !await lstatIfPresent(rootArg)) return [];
|
|
1035
|
+
return (await readBoundedDirectory(rootArg)).filter((name) => (
|
|
1036
|
+
controllerLogPattern.test(name) || upgradeLogPattern.test(name)
|
|
1037
|
+
));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private async assertNoConflictingWriters(): Promise<void> {
|
|
1041
|
+
const invoker = await readControllerProcessIdentity(this.options.invokerPid);
|
|
1042
|
+
if (!invoker) throw new Error('AGL home migration invoker identity cannot be verified.');
|
|
1043
|
+
const records = await this.options.listDataWriterProcesses();
|
|
1044
|
+
if (!Array.isArray(records) || records.length > 1_024) {
|
|
1045
|
+
throw new Error('AGL data-writer process inventory is invalid.');
|
|
1046
|
+
}
|
|
1047
|
+
for (const record of records) {
|
|
1048
|
+
if (
|
|
1049
|
+
record.identity.pid === invoker.pid
|
|
1050
|
+
&& record.identity.fingerprint === invoker.fingerprint
|
|
1051
|
+
) continue;
|
|
1052
|
+
throw new Error(`AGL home migration is blocked by ${record.kind} writer PID ${record.identity.pid}.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private async assertGitStateRelocatable(): Promise<void> {
|
|
1057
|
+
if (!this.options.legacyPaths.legacySourceEnabled) return;
|
|
1058
|
+
const root = plugins.path.join(this.options.legacyPaths.activeDataRoot, 'git-reversion');
|
|
1059
|
+
if (!await lstatIfPresent(root)) return;
|
|
1060
|
+
await assertSafeSourceTree(root);
|
|
1061
|
+
for (const owner of await readBoundedDirectory(root)) {
|
|
1062
|
+
if (!credentialHashPattern.test(owner)) {
|
|
1063
|
+
throw new Error(`Unexpected AGL Git reversion owner directory: ${owner}`);
|
|
1064
|
+
}
|
|
1065
|
+
const ownerRoot = plugins.path.join(root, owner);
|
|
1066
|
+
const ownerStats = await plugins.fs.promises.lstat(ownerRoot, { bigint: true });
|
|
1067
|
+
assertOwnedNode(ownerRoot, ownerStats, 'directory');
|
|
1068
|
+
for (const name of ['worktrees', 'worktree-records']) {
|
|
1069
|
+
const directory = plugins.path.join(ownerRoot, name);
|
|
1070
|
+
const stats = await lstatIfPresent(directory);
|
|
1071
|
+
if (stats && (await readBoundedDirectory(directory)).length > 0) {
|
|
1072
|
+
throw new Error(`AGL home migration is blocked by active Git worktree state: ${directory}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private async runLocked(): Promise<IAGLHomeMigrationResult> {
|
|
1079
|
+
let journal = await readPrivateJournal(this.journalPath);
|
|
1080
|
+
if (journal?.phase === 'target-committed') {
|
|
1081
|
+
this.assertJournalBinding(journal);
|
|
1082
|
+
await this.assertJournalRootIdentity(journal);
|
|
1083
|
+
return {
|
|
1084
|
+
directoryPath: this.options.paths.root,
|
|
1085
|
+
databaseConfig: { ...this.options.databaseConfig },
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
await plugins.fs.promises.mkdir(this.options.paths.root, { recursive: true, mode: 0o700 });
|
|
1089
|
+
await plugins.fs.promises.chmod(this.options.paths.root, 0o700);
|
|
1090
|
+
await plugins.fs.promises.mkdir(this.options.paths.migration, { recursive: true, mode: 0o700 });
|
|
1091
|
+
await plugins.fs.promises.chmod(this.options.paths.migration, 0o700);
|
|
1092
|
+
if (!journal) {
|
|
1093
|
+
const rootIdentity = identityFromStats(await plugins.fs.promises.lstat(
|
|
1094
|
+
this.options.paths.root,
|
|
1095
|
+
{ bigint: true },
|
|
1096
|
+
));
|
|
1097
|
+
const sources: ISourceIdentity[] = [];
|
|
1098
|
+
for (const path of [
|
|
1099
|
+
this.options.legacyPaths.legacySourceEnabled
|
|
1100
|
+
? this.options.legacyPaths.activeDataRoot
|
|
1101
|
+
: undefined,
|
|
1102
|
+
this.options.legacyPaths.legacyConfigRoot,
|
|
1103
|
+
this.options.legacyPaths.legacyStateRoot,
|
|
1104
|
+
]) {
|
|
1105
|
+
if (!path) continue;
|
|
1106
|
+
const stats = await lstatIfPresent(path);
|
|
1107
|
+
if (stats) sources.push({ path, ...identityFromStats(stats) });
|
|
1108
|
+
}
|
|
1109
|
+
journal = {
|
|
1110
|
+
version: migrationVersion,
|
|
1111
|
+
phase: 'target-staged',
|
|
1112
|
+
mode: this.options.legacyPaths.development ? 'development' : 'installed',
|
|
1113
|
+
root: this.options.paths.root,
|
|
1114
|
+
rootDevice: rootIdentity.device,
|
|
1115
|
+
rootInode: rootIdentity.inode,
|
|
1116
|
+
nonce: plugins.crypto.randomBytes(32).toString('hex'),
|
|
1117
|
+
sources,
|
|
1118
|
+
credentialRelocations: [],
|
|
1119
|
+
legacyUploadRoots: [],
|
|
1120
|
+
completedOperations: [],
|
|
1121
|
+
};
|
|
1122
|
+
await this.writeJournal(journal);
|
|
1123
|
+
}
|
|
1124
|
+
this.assertJournalBinding(journal);
|
|
1125
|
+
await this.migrateDatabase(journal);
|
|
1126
|
+
await this.migrateCredentials(journal);
|
|
1127
|
+
if (this.options.legacyPaths.legacySourceEnabled) {
|
|
1128
|
+
await this.moveDirect(
|
|
1129
|
+
plugins.path.join(this.options.legacyPaths.activeDataRoot, 'git-reversion'),
|
|
1130
|
+
this.options.paths.gitReversion,
|
|
1131
|
+
'git-reversion',
|
|
1132
|
+
'directory',
|
|
1133
|
+
journal,
|
|
1134
|
+
);
|
|
1135
|
+
await this.moveDirect(
|
|
1136
|
+
plugins.path.join(this.options.legacyPaths.activeDataRoot, 'browser-runtime'),
|
|
1137
|
+
this.options.paths.browserRuntime,
|
|
1138
|
+
'browser-runtime-active',
|
|
1139
|
+
'directory',
|
|
1140
|
+
journal,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
for (const [root, category] of [
|
|
1144
|
+
[
|
|
1145
|
+
this.options.legacyPaths.legacySourceEnabled
|
|
1146
|
+
? this.options.legacyPaths.activeDataRoot
|
|
1147
|
+
: undefined,
|
|
1148
|
+
'data',
|
|
1149
|
+
],
|
|
1150
|
+
[this.options.legacyPaths.legacyConfigRoot, 'config'],
|
|
1151
|
+
[this.options.legacyPaths.legacyStateRoot, 'state'],
|
|
1152
|
+
] as const) {
|
|
1153
|
+
await this.migrateLogs(root, category, journal);
|
|
1154
|
+
}
|
|
1155
|
+
await this.moveLegacyMetadata(journal);
|
|
1156
|
+
await this.cleanupLegacyUploadRoots(journal);
|
|
1157
|
+
await this.cleanupLegacyRoots();
|
|
1158
|
+
if ((await this.inspectSourceInventories()).length > 0) {
|
|
1159
|
+
throw new Error('AGL legacy source inventory is not empty after migration.');
|
|
1160
|
+
}
|
|
1161
|
+
for (const directory of [
|
|
1162
|
+
this.options.paths.database,
|
|
1163
|
+
this.options.paths.credentials,
|
|
1164
|
+
this.options.paths.gitReversion,
|
|
1165
|
+
this.options.paths.logs,
|
|
1166
|
+
this.options.paths.legacyLogs,
|
|
1167
|
+
this.options.paths.upgrade,
|
|
1168
|
+
this.options.paths.cache,
|
|
1169
|
+
this.options.paths.runtime,
|
|
1170
|
+
this.options.paths.browserRuntime,
|
|
1171
|
+
this.options.paths.uploads,
|
|
1172
|
+
this.options.paths.sockets,
|
|
1173
|
+
this.options.paths.openCodeRuntime,
|
|
1174
|
+
this.options.paths.migration,
|
|
1175
|
+
]) {
|
|
1176
|
+
await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1177
|
+
await plugins.fs.promises.chmod(directory, 0o700);
|
|
1178
|
+
}
|
|
1179
|
+
await this.inspectTargetInventory(true);
|
|
1180
|
+
await assertSafeSourceTree(this.options.paths.root, journal.rootDevice);
|
|
1181
|
+
journal = { ...journal, phase: 'target-committed' };
|
|
1182
|
+
await this.writeJournal(journal);
|
|
1183
|
+
return {
|
|
1184
|
+
directoryPath: this.options.paths.root,
|
|
1185
|
+
databaseConfig: { ...this.options.databaseConfig },
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
private async migrateDatabase(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1190
|
+
const source = plugins.path.join(this.options.legacyPaths.activeDataRoot, 'smartdb');
|
|
1191
|
+
const sourceStats = this.options.legacyPaths.legacySourceEnabled
|
|
1192
|
+
? await lstatIfPresent(source)
|
|
1193
|
+
: undefined;
|
|
1194
|
+
const destinationStats = await lstatIfPresent(this.options.paths.database);
|
|
1195
|
+
if (this.usesDefaultEmbeddedDatabase()) {
|
|
1196
|
+
if (sourceStats?.isDirectory()) {
|
|
1197
|
+
const receipt = await this.relocateDatabase({
|
|
1198
|
+
sourceFolderPath: source,
|
|
1199
|
+
destinationFolderPath: this.options.paths.database,
|
|
1200
|
+
relocationId: `agl-home-v23:${journalArg.nonce}:database`,
|
|
1201
|
+
}, this.options.signal);
|
|
1202
|
+
if (
|
|
1203
|
+
receipt.sourceFolderPath !== source
|
|
1204
|
+
|| receipt.destinationFolderPath !== this.options.paths.database
|
|
1205
|
+
|| receipt.sourceReceiptRetained !== true
|
|
1206
|
+
|| receipt.storageRootDevice !== sourceStats.dev.toString(10)
|
|
1207
|
+
|| receipt.storageRootInode !== sourceStats.ino.toString(10)
|
|
1208
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.providerRootId)
|
|
1209
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.receiptSha256)
|
|
1210
|
+
) throw new Error('AGL SmartDB relocation receipt does not match its operation.');
|
|
1211
|
+
const relocated = await plugins.fs.promises.lstat(
|
|
1212
|
+
this.options.paths.database,
|
|
1213
|
+
{ bigint: true },
|
|
1214
|
+
);
|
|
1215
|
+
if (
|
|
1216
|
+
!relocated.isDirectory()
|
|
1217
|
+
|| relocated.dev.toString(10) !== receipt.storageRootDevice
|
|
1218
|
+
|| relocated.ino.toString(10) !== receipt.storageRootInode
|
|
1219
|
+
) throw new Error('AGL SmartDB destination identity does not match its receipt.');
|
|
1220
|
+
const retainedReceiptStats = await plugins.fs.promises.lstat(source, { bigint: true });
|
|
1221
|
+
if (
|
|
1222
|
+
!retainedReceiptStats.isFile()
|
|
1223
|
+
|| retainedReceiptStats.uid !== BigInt(currentUid())
|
|
1224
|
+
|| Number(retainedReceiptStats.mode & 0o777n) !== 0o600
|
|
1225
|
+
|| retainedReceiptStats.size < 2n
|
|
1226
|
+
|| retainedReceiptStats.size > 16_384n
|
|
1227
|
+
) throw new Error('AGL SmartDB retained receipt is unsafe.');
|
|
1228
|
+
const retainedReceipt = JSON.parse(
|
|
1229
|
+
await plugins.fs.promises.readFile(source, 'utf8'),
|
|
1230
|
+
) as Record<string, unknown>;
|
|
1231
|
+
for (const key of [
|
|
1232
|
+
'format',
|
|
1233
|
+
'version',
|
|
1234
|
+
'relocationId',
|
|
1235
|
+
'sourceFolderPath',
|
|
1236
|
+
'destinationFolderPath',
|
|
1237
|
+
'providerRootId',
|
|
1238
|
+
'storageRootDevice',
|
|
1239
|
+
'storageRootInode',
|
|
1240
|
+
'receiptSha256',
|
|
1241
|
+
'sourceReceiptRetained',
|
|
1242
|
+
] as const) {
|
|
1243
|
+
if (retainedReceipt[key] !== receipt[key]) {
|
|
1244
|
+
throw new Error('AGL SmartDB retained receipt does not match its API receipt.');
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
} else if (sourceStats && !sourceStats.isFile()) {
|
|
1248
|
+
throw new Error(`Legacy AGL database path is unsafe: ${source}`);
|
|
1249
|
+
} else if (!sourceStats && destinationStats && !destinationStats.isDirectory()) {
|
|
1250
|
+
throw new Error('AGL database destination is not a directory.');
|
|
1251
|
+
} else if (sourceStats?.isFile() && !destinationStats) {
|
|
1252
|
+
throw new Error('AGL database relocation receipt exists without its destination.');
|
|
1253
|
+
}
|
|
1254
|
+
await this.moveDirect(
|
|
1255
|
+
source,
|
|
1256
|
+
plugins.path.join(this.options.paths.migration, 'active-database-relocation-receipt.json'),
|
|
1257
|
+
'database-relocation-receipt',
|
|
1258
|
+
'file',
|
|
1259
|
+
journalArg,
|
|
1260
|
+
);
|
|
1261
|
+
} else if (!this.isConfiguredDatabaseSource(source) && (sourceStats || destinationStats)) {
|
|
1262
|
+
await this.moveDirect(
|
|
1263
|
+
source,
|
|
1264
|
+
plugins.path.join(this.options.paths.migration, 'legacy-unused-local-database'),
|
|
1265
|
+
'unused-local-database',
|
|
1266
|
+
sourceStats?.isFile() ? 'file' : 'directory',
|
|
1267
|
+
journalArg,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
if (this.options.legacyPaths.legacyStateRoot) {
|
|
1271
|
+
const historicalDatabase = plugins.path.join(
|
|
1272
|
+
this.options.legacyPaths.legacyStateRoot,
|
|
1273
|
+
'smartdb',
|
|
1274
|
+
);
|
|
1275
|
+
if (this.isConfiguredDatabaseSource(historicalDatabase)) return;
|
|
1276
|
+
await this.moveDirect(
|
|
1277
|
+
historicalDatabase,
|
|
1278
|
+
plugins.path.join(this.options.paths.migration, 'legacy-database-backup-v1'),
|
|
1279
|
+
'legacy-database-backup-v1',
|
|
1280
|
+
'directory',
|
|
1281
|
+
journalArg,
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
private async migrateCredentials(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1287
|
+
const destinationRoot = plugins.path.join(
|
|
1288
|
+
this.options.paths.credentials,
|
|
1289
|
+
'flex-provider-credentials',
|
|
1290
|
+
);
|
|
1291
|
+
const sourceRoots = [
|
|
1292
|
+
...(this.options.legacyPaths.legacySourceEnabled
|
|
1293
|
+
? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')]
|
|
1294
|
+
: []),
|
|
1295
|
+
...(this.options.legacyPaths.legacyConfigRoot
|
|
1296
|
+
? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')]
|
|
1297
|
+
: []),
|
|
1298
|
+
];
|
|
1299
|
+
const sourceByHash = new Map<string, string>();
|
|
1300
|
+
for (const root of sourceRoots) {
|
|
1301
|
+
const stats = await lstatIfPresent(root);
|
|
1302
|
+
if (!stats) continue;
|
|
1303
|
+
assertOwnedNode(root, stats, 'directory', true);
|
|
1304
|
+
const hashes = await readBoundedDirectory(root);
|
|
1305
|
+
if (hashes.length > maximumCredentialStores) {
|
|
1306
|
+
throw new Error('Legacy AGL credential root has too many stores.');
|
|
1307
|
+
}
|
|
1308
|
+
for (const hash of hashes) {
|
|
1309
|
+
if (!credentialHashPattern.test(hash) || sourceByHash.has(hash)) {
|
|
1310
|
+
throw new Error(`Legacy AGL credential store is ambiguous: ${hash}`);
|
|
1311
|
+
}
|
|
1312
|
+
sourceByHash.set(hash, plugins.path.join(root, hash));
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
const destinationHashes: string[] = [];
|
|
1316
|
+
const destinationRootStats = await lstatIfPresent(destinationRoot);
|
|
1317
|
+
if (destinationRootStats) {
|
|
1318
|
+
assertOwnedNode(destinationRoot, destinationRootStats, 'directory', true);
|
|
1319
|
+
for (const hash of await readBoundedDirectory(destinationRoot)) {
|
|
1320
|
+
if (!credentialHashPattern.test(hash)) {
|
|
1321
|
+
throw new Error(`AGL credential destination contains an unexpected entry: ${hash}`);
|
|
1322
|
+
}
|
|
1323
|
+
destinationHashes.push(hash);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const bindingByHash = new Map(
|
|
1327
|
+
journalArg.credentialRelocations.map((binding) => [binding.controllerHash, binding]),
|
|
1328
|
+
);
|
|
1329
|
+
const hashes = [...new Set([
|
|
1330
|
+
...sourceByHash.keys(),
|
|
1331
|
+
...destinationHashes,
|
|
1332
|
+
...bindingByHash.keys(),
|
|
1333
|
+
])].sort();
|
|
1334
|
+
if (hashes.length > maximumCredentialStores) {
|
|
1335
|
+
throw new Error('AGL credential migration contains too many stores.');
|
|
1336
|
+
}
|
|
1337
|
+
if (hashes.length > 0 && !destinationRootStats) {
|
|
1338
|
+
await plugins.fs.promises.mkdir(destinationRoot, { recursive: true, mode: 0o700 });
|
|
1339
|
+
await plugins.fs.promises.chmod(this.options.paths.credentials, 0o700);
|
|
1340
|
+
await plugins.fs.promises.chmod(destinationRoot, 0o700);
|
|
1341
|
+
}
|
|
1342
|
+
for (const hash of hashes) {
|
|
1343
|
+
let binding = bindingByHash.get(hash);
|
|
1344
|
+
const observedSource = sourceByHash.get(hash);
|
|
1345
|
+
if (binding && observedSource && binding.sourceDirectory !== observedSource) {
|
|
1346
|
+
throw new Error(`AGL credential source changed after relocation intent: ${hash}`);
|
|
1347
|
+
}
|
|
1348
|
+
if (!binding) {
|
|
1349
|
+
if (!observedSource) {
|
|
1350
|
+
throw new Error(`AGL credential destination has no relocation intent: ${hash}`);
|
|
1351
|
+
}
|
|
1352
|
+
binding = { controllerHash: hash, sourceDirectory: observedSource };
|
|
1353
|
+
journalArg.credentialRelocations.push(binding);
|
|
1354
|
+
journalArg.credentialRelocations.sort((left, right) => (
|
|
1355
|
+
left.controllerHash.localeCompare(right.controllerHash)
|
|
1356
|
+
));
|
|
1357
|
+
bindingByHash.set(hash, binding);
|
|
1358
|
+
await this.writeJournal(journalArg);
|
|
1359
|
+
}
|
|
1360
|
+
const source = binding.sourceDirectory;
|
|
1361
|
+
const destination = plugins.path.join(destinationRoot, hash);
|
|
1362
|
+
const sourceStats = await lstatIfPresent(source);
|
|
1363
|
+
const destinationStats = await lstatIfPresent(destination);
|
|
1364
|
+
if (sourceStats && destinationStats) {
|
|
1365
|
+
throw new Error(`AGL credential store exists at both source and destination: ${hash}`);
|
|
1366
|
+
}
|
|
1367
|
+
if (sourceStats) assertOwnedNode(source, sourceStats, 'directory', true);
|
|
1368
|
+
if (destinationStats) assertOwnedNode(destination, destinationStats, 'directory', true);
|
|
1369
|
+
const operation = `credential:${hash}`;
|
|
1370
|
+
if (journalArg.completedOperations.includes(operation)) {
|
|
1371
|
+
if (sourceStats || !destinationStats) {
|
|
1372
|
+
throw new Error(`Completed AGL credential relocation has an invalid location: ${hash}`);
|
|
1373
|
+
}
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (!sourceStats && !destinationStats) {
|
|
1377
|
+
throw new Error(`AGL credential relocation has no source or destination: ${hash}`);
|
|
1378
|
+
}
|
|
1379
|
+
await this.relocateCredentials({
|
|
1380
|
+
controllerHash: hash,
|
|
1381
|
+
sourceDirectory: source,
|
|
1382
|
+
destinationDirectory: destination,
|
|
1383
|
+
}, this.options.signal);
|
|
1384
|
+
if (await lstatIfPresent(source)) {
|
|
1385
|
+
throw new Error(`AGL credential source remains after relocation: ${hash}`);
|
|
1386
|
+
}
|
|
1387
|
+
const migrated = await lstatIfPresent(destination);
|
|
1388
|
+
if (!migrated) throw new Error(`AGL credential relocation was not confirmed: ${hash}`);
|
|
1389
|
+
assertOwnedNode(destination, migrated, 'directory', true);
|
|
1390
|
+
await this.markOperation(journalArg, operation);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
private async migrateLogs(
|
|
1395
|
+
rootArg: string | undefined,
|
|
1396
|
+
categoryArg: string,
|
|
1397
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
1398
|
+
): Promise<void> {
|
|
1399
|
+
if (!rootArg || !await lstatIfPresent(rootArg)) return;
|
|
1400
|
+
for (const name of await this.listLegacyLogNames(rootArg)) {
|
|
1401
|
+
await this.moveDirect(
|
|
1402
|
+
plugins.path.join(rootArg, name),
|
|
1403
|
+
plugins.path.join(this.options.paths.legacyLogs, categoryArg, name),
|
|
1404
|
+
`log:${categoryArg}:${name}`,
|
|
1405
|
+
'file',
|
|
1406
|
+
journalArg,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
private async moveLegacyMetadata(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1412
|
+
const legacyConfigDatabase = this.options.legacyPaths.legacyConfigRoot
|
|
1413
|
+
? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'smartdb')
|
|
1414
|
+
: undefined;
|
|
1415
|
+
const moves: Array<[string | undefined, string, string, 'file' | 'directory']> = [
|
|
1416
|
+
[
|
|
1417
|
+
this.options.legacyPaths.legacySourceEnabled
|
|
1418
|
+
? plugins.path.join(
|
|
1419
|
+
this.options.legacyPaths.activeDataRoot,
|
|
1420
|
+
'.hcon-controller-data-root-marker.json',
|
|
1421
|
+
)
|
|
1422
|
+
: undefined,
|
|
1423
|
+
'legacy-v2-target-marker.json',
|
|
1424
|
+
'legacy-v2-target-marker',
|
|
1425
|
+
'file',
|
|
1426
|
+
],
|
|
1427
|
+
[
|
|
1428
|
+
this.options.legacyPaths.legacySourceEnabled
|
|
1429
|
+
? plugins.path.join(
|
|
1430
|
+
this.options.legacyPaths.activeDataRoot,
|
|
1431
|
+
'.smartdb-location-migration.json',
|
|
1432
|
+
)
|
|
1433
|
+
: undefined,
|
|
1434
|
+
'legacy-v1-target-journal.json',
|
|
1435
|
+
'legacy-v1-target-journal',
|
|
1436
|
+
'file',
|
|
1437
|
+
],
|
|
1438
|
+
[
|
|
1439
|
+
this.options.legacyPaths.legacyConfigRoot
|
|
1440
|
+
? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, '.smartdb-location-migration.json')
|
|
1441
|
+
: undefined,
|
|
1442
|
+
'legacy-v1-source-journal.json',
|
|
1443
|
+
'legacy-v1-source-journal',
|
|
1444
|
+
'file',
|
|
1445
|
+
],
|
|
1446
|
+
[
|
|
1447
|
+
legacyConfigDatabase && !this.isConfiguredDatabaseSource(legacyConfigDatabase)
|
|
1448
|
+
? legacyConfigDatabase
|
|
1449
|
+
: undefined,
|
|
1450
|
+
'legacy-v2-database-relocation-receipt.json',
|
|
1451
|
+
'legacy-v2-database-relocation-receipt',
|
|
1452
|
+
'file',
|
|
1453
|
+
],
|
|
1454
|
+
[
|
|
1455
|
+
this.options.legacyPaths.legacyConfigRoot
|
|
1456
|
+
? plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'browser-runtime')
|
|
1457
|
+
: undefined,
|
|
1458
|
+
'legacy-browser-runtime-config',
|
|
1459
|
+
'legacy-browser-runtime-config',
|
|
1460
|
+
'directory',
|
|
1461
|
+
],
|
|
1462
|
+
[
|
|
1463
|
+
this.options.legacyPaths.legacyStateRoot
|
|
1464
|
+
? plugins.path.join(this.options.legacyPaths.legacyStateRoot, 'browser-runtime')
|
|
1465
|
+
: undefined,
|
|
1466
|
+
'legacy-browser-runtime-state',
|
|
1467
|
+
'legacy-browser-runtime-state',
|
|
1468
|
+
'directory',
|
|
1469
|
+
],
|
|
1470
|
+
[
|
|
1471
|
+
this.options.legacyPaths.v2JournalPath,
|
|
1472
|
+
'legacy-v2-data-root-journal.json',
|
|
1473
|
+
'legacy-v2-data-root-journal',
|
|
1474
|
+
'file',
|
|
1475
|
+
],
|
|
1476
|
+
];
|
|
1477
|
+
for (const [source, destinationName, operation, kind] of moves) {
|
|
1478
|
+
if (!source) continue;
|
|
1479
|
+
await this.moveDirect(
|
|
1480
|
+
source,
|
|
1481
|
+
plugins.path.join(this.options.paths.migration, destinationName),
|
|
1482
|
+
operation,
|
|
1483
|
+
kind,
|
|
1484
|
+
journalArg,
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private legacyUploadTombstonePath(
|
|
1490
|
+
bindingArg: ILegacyUploadRootBinding,
|
|
1491
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
1492
|
+
): string {
|
|
1493
|
+
return `${bindingArg.path}.agl-v23-${journalArg.nonce}.removing`;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
private async scanLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise<{
|
|
1497
|
+
sources: string[];
|
|
1498
|
+
tombstones: Map<string, string>;
|
|
1499
|
+
}> {
|
|
1500
|
+
const parentStats = await plugins.fs.promises.lstat(this.legacyUploadTempDirectory);
|
|
1501
|
+
if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) {
|
|
1502
|
+
throw new Error('The legacy controller upload temp directory is unsafe.');
|
|
1503
|
+
}
|
|
1504
|
+
const sources: string[] = [];
|
|
1505
|
+
const tombstones = new Map<string, string>();
|
|
1506
|
+
let ownedMatches = 0;
|
|
1507
|
+
const directory = await plugins.fs.promises.opendir(this.legacyUploadTempDirectory);
|
|
1508
|
+
for await (const entry of directory) {
|
|
1509
|
+
const tombstoneMatch = legacyUploadTombstonePattern.exec(entry.name);
|
|
1510
|
+
if (!legacyUploadRootPattern.test(entry.name) && !tombstoneMatch) continue;
|
|
1511
|
+
const path = plugins.path.join(this.legacyUploadTempDirectory, entry.name);
|
|
1512
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
1513
|
+
if (stats.uid !== BigInt(currentUid())) continue;
|
|
1514
|
+
ownedMatches += 1;
|
|
1515
|
+
if (ownedMatches > maximumLegacyUploadRoots * 2) {
|
|
1516
|
+
throw new Error('The legacy controller upload temp directory has too many owned roots.');
|
|
1517
|
+
}
|
|
1518
|
+
assertOwnedNode(path, stats, 'directory', true);
|
|
1519
|
+
if (tombstoneMatch) {
|
|
1520
|
+
if (tombstoneMatch[1] !== journalArg.nonce) {
|
|
1521
|
+
throw new Error(`Unexpected legacy controller upload tombstone: ${path}`);
|
|
1522
|
+
}
|
|
1523
|
+
const sourceName = entry.name.slice(0, entry.name.indexOf('.agl-v23-'));
|
|
1524
|
+
const sourcePath = plugins.path.join(this.legacyUploadTempDirectory, sourceName);
|
|
1525
|
+
if (tombstones.has(sourcePath)) {
|
|
1526
|
+
throw new Error(`Duplicate legacy controller upload tombstone: ${path}`);
|
|
1527
|
+
}
|
|
1528
|
+
tombstones.set(sourcePath, path);
|
|
1529
|
+
} else {
|
|
1530
|
+
sources.push(path);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
sources.sort();
|
|
1534
|
+
return { sources, tombstones };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private async assertNoLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1538
|
+
const inventory = await this.scanLegacyUploadRoots(journalArg);
|
|
1539
|
+
if (inventory.sources.length > 0 || inventory.tombstones.size > 0) {
|
|
1540
|
+
throw new Error('Legacy controller upload state appeared after cleanup completed.');
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
private async processLegacyUploadRootBinding(
|
|
1545
|
+
bindingArg: ILegacyUploadRootBinding,
|
|
1546
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
1547
|
+
): Promise<void> {
|
|
1548
|
+
const tombstonePath = this.legacyUploadTombstonePath(bindingArg, journalArg);
|
|
1549
|
+
const [sourceStats, tombstoneStats] = await Promise.all([
|
|
1550
|
+
lstatIfPresent(bindingArg.path),
|
|
1551
|
+
lstatIfPresent(tombstonePath),
|
|
1552
|
+
]);
|
|
1553
|
+
if (sourceStats && tombstoneStats) {
|
|
1554
|
+
throw new Error(`Legacy controller upload source and tombstone both exist: ${bindingArg.path}`);
|
|
1555
|
+
}
|
|
1556
|
+
const expectedIdentity = { device: bindingArg.device, inode: bindingArg.inode };
|
|
1557
|
+
if (sourceStats) {
|
|
1558
|
+
if (!identitiesEqual(identityFromStats(sourceStats), expectedIdentity)) {
|
|
1559
|
+
throw new Error(`Legacy controller upload root reappeared with a new identity: ${bindingArg.path}`);
|
|
1560
|
+
}
|
|
1561
|
+
assertOwnedNode(bindingArg.path, sourceStats, 'directory', true);
|
|
1562
|
+
await assertSafeLegacyUploadTree(bindingArg.path, bindingArg.device);
|
|
1563
|
+
await this.preflight();
|
|
1564
|
+
await plugins.fs.promises.rename(bindingArg.path, tombstonePath);
|
|
1565
|
+
await syncDirectory(this.legacyUploadTempDirectory);
|
|
1566
|
+
} else if (tombstoneStats) {
|
|
1567
|
+
if (!identitiesEqual(identityFromStats(tombstoneStats), expectedIdentity)) {
|
|
1568
|
+
throw new Error(`Legacy controller upload tombstone identity changed: ${tombstonePath}`);
|
|
1569
|
+
}
|
|
1570
|
+
await syncDirectory(this.legacyUploadTempDirectory);
|
|
1571
|
+
} else {
|
|
1572
|
+
await syncDirectory(this.legacyUploadTempDirectory);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const movedStats = await plugins.fs.promises.lstat(tombstonePath, { bigint: true });
|
|
1576
|
+
if (!identitiesEqual(identityFromStats(movedStats), expectedIdentity)) {
|
|
1577
|
+
throw new Error(`Legacy controller upload tombstone changed before cleanup: ${tombstonePath}`);
|
|
1578
|
+
}
|
|
1579
|
+
assertOwnedNode(tombstonePath, movedStats, 'directory', true);
|
|
1580
|
+
await assertSafeLegacyUploadTree(tombstonePath, bindingArg.device);
|
|
1581
|
+
await this.preflight();
|
|
1582
|
+
await plugins.fs.promises.rm(tombstonePath, { recursive: true });
|
|
1583
|
+
await syncDirectory(this.legacyUploadTempDirectory);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
private async cleanupLegacyUploadRoots(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1587
|
+
if (journalArg.completedOperations.includes(legacyUploadCleanupOperation)) {
|
|
1588
|
+
await this.assertNoLegacyUploadRoots(journalArg);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1592
|
+
// Old upload roots have no owner marker. Repeat the complete writer and
|
|
1593
|
+
// socket preflight immediately before binding and deleting them.
|
|
1594
|
+
await this.preflight();
|
|
1595
|
+
const inventory = await this.scanLegacyUploadRoots(journalArg);
|
|
1596
|
+
const bindingByPath = new Map(
|
|
1597
|
+
journalArg.legacyUploadRoots.map((bindingArg) => [bindingArg.path, bindingArg]),
|
|
1598
|
+
);
|
|
1599
|
+
for (const sourcePath of inventory.sources) {
|
|
1600
|
+
const sourceStats = await plugins.fs.promises.lstat(sourcePath, { bigint: true });
|
|
1601
|
+
const existing = bindingByPath.get(sourcePath);
|
|
1602
|
+
if (existing) {
|
|
1603
|
+
if (!identitiesEqual(identityFromStats(sourceStats), existing)) {
|
|
1604
|
+
throw new Error(`Legacy controller upload root reappeared with a new identity: ${sourcePath}`);
|
|
1605
|
+
}
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (journalArg.legacyUploadRoots.length >= maximumLegacyUploadRoots) {
|
|
1609
|
+
throw new Error('The legacy controller upload root count exceeds its migration limit.');
|
|
1610
|
+
}
|
|
1611
|
+
assertOwnedNode(sourcePath, sourceStats, 'directory', true);
|
|
1612
|
+
await assertSafeLegacyUploadTree(sourcePath, sourceStats.dev.toString(10));
|
|
1613
|
+
const binding = { path: sourcePath, ...identityFromStats(sourceStats) };
|
|
1614
|
+
journalArg.legacyUploadRoots.push(binding);
|
|
1615
|
+
bindingByPath.set(sourcePath, binding);
|
|
1616
|
+
}
|
|
1617
|
+
for (const sourcePath of inventory.tombstones.keys()) {
|
|
1618
|
+
if (!bindingByPath.has(sourcePath)) {
|
|
1619
|
+
throw new Error(`Unbound legacy controller upload tombstone: ${sourcePath}`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
journalArg.legacyUploadRoots.sort((leftArg, rightArg) => (
|
|
1623
|
+
leftArg.path < rightArg.path ? -1 : leftArg.path > rightArg.path ? 1 : 0
|
|
1624
|
+
));
|
|
1625
|
+
await this.writeJournal(journalArg);
|
|
1626
|
+
for (const binding of journalArg.legacyUploadRoots) {
|
|
1627
|
+
await this.processLegacyUploadRootBinding(binding, journalArg);
|
|
1628
|
+
}
|
|
1629
|
+
const remaining = await this.scanLegacyUploadRoots(journalArg);
|
|
1630
|
+
if (remaining.sources.length === 0 && remaining.tombstones.size === 0) {
|
|
1631
|
+
await this.markOperation(journalArg, legacyUploadCleanupOperation);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
for (const sourcePath of remaining.sources) {
|
|
1635
|
+
const existing = bindingByPath.get(sourcePath);
|
|
1636
|
+
if (!existing) continue;
|
|
1637
|
+
const stats = await plugins.fs.promises.lstat(sourcePath, { bigint: true });
|
|
1638
|
+
if (!identitiesEqual(identityFromStats(stats), existing)) {
|
|
1639
|
+
throw new Error(`Legacy controller upload root reappeared with a new identity: ${sourcePath}`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
throw new Error('Legacy controller upload roots changed repeatedly during migration.');
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private async moveDirect(
|
|
1647
|
+
sourceArg: string,
|
|
1648
|
+
destinationArg: string,
|
|
1649
|
+
operationArg: string,
|
|
1650
|
+
kindArg: 'file' | 'directory',
|
|
1651
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
1652
|
+
): Promise<void> {
|
|
1653
|
+
const source = await lstatIfPresent(sourceArg);
|
|
1654
|
+
const destination = await lstatIfPresent(destinationArg);
|
|
1655
|
+
if (journalArg.completedOperations.includes(operationArg)) {
|
|
1656
|
+
if (source) {
|
|
1657
|
+
throw new Error(`Completed AGL migration source reappeared: ${sourceArg}`);
|
|
1658
|
+
}
|
|
1659
|
+
if (destination) {
|
|
1660
|
+
assertOwnedNode(destinationArg, destination, kindArg, kindArg === 'directory');
|
|
1661
|
+
}
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (source && destination) {
|
|
1665
|
+
throw new Error(`AGL migration source and destination both exist: ${sourceArg}`);
|
|
1666
|
+
}
|
|
1667
|
+
if (!source && !destination) return;
|
|
1668
|
+
if (source) {
|
|
1669
|
+
assertOwnedNode(sourceArg, source, kindArg, kindArg === 'directory');
|
|
1670
|
+
if (kindArg === 'directory') await assertSafeSourceTree(sourceArg);
|
|
1671
|
+
const parent = plugins.path.dirname(destinationArg);
|
|
1672
|
+
await plugins.fs.promises.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1673
|
+
await plugins.fs.promises.chmod(parent, 0o700);
|
|
1674
|
+
await plugins.fs.promises.rename(sourceArg, destinationArg);
|
|
1675
|
+
await syncDirectory(plugins.path.dirname(sourceArg));
|
|
1676
|
+
await syncDirectory(parent);
|
|
1677
|
+
} else {
|
|
1678
|
+
assertOwnedNode(destinationArg, destination!, kindArg, kindArg === 'directory');
|
|
1679
|
+
}
|
|
1680
|
+
await this.markOperation(journalArg, operationArg);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
private async markOperation(
|
|
1684
|
+
journalArg: IAGLHomeMigrationJournal,
|
|
1685
|
+
operationArg: string,
|
|
1686
|
+
): Promise<void> {
|
|
1687
|
+
if (!isKnownOperationName(operationArg)) {
|
|
1688
|
+
throw new Error(`Unknown AGL migration operation: ${operationArg}`);
|
|
1689
|
+
}
|
|
1690
|
+
if (journalArg.completedOperations.includes(operationArg)) return;
|
|
1691
|
+
journalArg.completedOperations.push(operationArg);
|
|
1692
|
+
journalArg.completedOperations.sort();
|
|
1693
|
+
await this.writeJournal(journalArg);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
private async cleanupLegacyRoots(): Promise<void> {
|
|
1697
|
+
const credentialRoots = [
|
|
1698
|
+
...(this.options.legacyPaths.legacySourceEnabled
|
|
1699
|
+
? [plugins.path.join(this.options.legacyPaths.activeDataRoot, 'flex-provider-credentials')]
|
|
1700
|
+
: []),
|
|
1701
|
+
...(this.options.legacyPaths.legacyConfigRoot
|
|
1702
|
+
? [plugins.path.join(this.options.legacyPaths.legacyConfigRoot, 'flex-provider-credentials')]
|
|
1703
|
+
: []),
|
|
1704
|
+
];
|
|
1705
|
+
for (const root of credentialRoots) await this.removeEmptyDirectory(root);
|
|
1706
|
+
if (!this.options.legacyPaths.development && this.options.legacyPaths.legacySourceEnabled) {
|
|
1707
|
+
await this.removeEmptyDirectory(this.options.legacyPaths.activeDataRoot);
|
|
1708
|
+
}
|
|
1709
|
+
if (this.options.legacyPaths.legacyConfigRoot) {
|
|
1710
|
+
await this.removeEmptyDirectory(this.options.legacyPaths.legacyConfigRoot);
|
|
1711
|
+
}
|
|
1712
|
+
if (this.options.legacyPaths.legacyStateRoot) {
|
|
1713
|
+
await this.removeEmptyDirectory(this.options.legacyPaths.legacyStateRoot);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
private async removeEmptyDirectory(pathArg: string): Promise<void> {
|
|
1718
|
+
const stats = await lstatIfPresent(pathArg);
|
|
1719
|
+
if (!stats) return;
|
|
1720
|
+
assertOwnedNode(pathArg, stats, 'directory');
|
|
1721
|
+
if ((await readBoundedDirectory(pathArg)).length !== 0) return;
|
|
1722
|
+
await plugins.fs.promises.rmdir(pathArg);
|
|
1723
|
+
await syncDirectory(plugins.path.dirname(pathArg));
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private async writeJournal(journalArg: IAGLHomeMigrationJournal): Promise<void> {
|
|
1727
|
+
const serialized = `${JSON.stringify(journalArg)}\n`;
|
|
1728
|
+
if (Buffer.byteLength(serialized, 'utf8') > maximumJournalBytes) {
|
|
1729
|
+
throw new Error('AGL home migration journal exceeds its size limit.');
|
|
1730
|
+
}
|
|
1731
|
+
const temporary = `${this.journalPath}.${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
1732
|
+
let handle: plugins.fs.promises.FileHandle | undefined;
|
|
1733
|
+
let temporaryIdentity: IFilesystemIdentity | undefined;
|
|
1734
|
+
try {
|
|
1735
|
+
handle = await plugins.fs.promises.open(
|
|
1736
|
+
temporary,
|
|
1737
|
+
plugins.fs.constants.O_WRONLY
|
|
1738
|
+
| plugins.fs.constants.O_CREAT
|
|
1739
|
+
| plugins.fs.constants.O_EXCL
|
|
1740
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
1741
|
+
0o600,
|
|
1742
|
+
);
|
|
1743
|
+
temporaryIdentity = identityFromStats(await handle.stat({ bigint: true }));
|
|
1744
|
+
await handle.chmod(0o600);
|
|
1745
|
+
await handle.writeFile(serialized, 'utf8');
|
|
1746
|
+
await handle.sync();
|
|
1747
|
+
await handle.close();
|
|
1748
|
+
handle = undefined;
|
|
1749
|
+
await plugins.fs.promises.rename(temporary, this.journalPath);
|
|
1750
|
+
temporaryIdentity = undefined;
|
|
1751
|
+
await syncDirectory(this.options.paths.migration);
|
|
1752
|
+
const persisted = await readPrivateJournal(this.journalPath);
|
|
1753
|
+
if (!persisted || JSON.stringify(persisted) !== JSON.stringify(journalArg)) {
|
|
1754
|
+
throw new Error('AGL home migration journal was not persisted exactly.');
|
|
1755
|
+
}
|
|
1756
|
+
} catch (errorArg) {
|
|
1757
|
+
const cleanupErrors: unknown[] = [];
|
|
1758
|
+
if (handle) {
|
|
1759
|
+
try {
|
|
1760
|
+
await handle.close();
|
|
1761
|
+
} catch (cleanupErrorArg) {
|
|
1762
|
+
cleanupErrors.push(cleanupErrorArg);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
if (temporaryIdentity) {
|
|
1766
|
+
try {
|
|
1767
|
+
const stats = await lstatIfPresent(temporary);
|
|
1768
|
+
if (!stats || !identitiesEqual(identityFromStats(stats), temporaryIdentity)) {
|
|
1769
|
+
throw new Error('AGL journal temporary identity changed before cleanup.');
|
|
1770
|
+
}
|
|
1771
|
+
await plugins.fs.promises.unlink(temporary);
|
|
1772
|
+
await syncDirectory(this.options.paths.migration);
|
|
1773
|
+
} catch (cleanupErrorArg) {
|
|
1774
|
+
cleanupErrors.push(cleanupErrorArg);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
if (cleanupErrors.length > 0) {
|
|
1778
|
+
throw new AggregateError(
|
|
1779
|
+
[errorArg, ...cleanupErrors],
|
|
1780
|
+
'AGL journal write failed and cleanup was incomplete.',
|
|
1781
|
+
{ cause: errorArg },
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
throw errorArg;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
private async cleanupJournalTemporaryFiles(): Promise<void> {
|
|
1789
|
+
const migrationStats = await lstatIfPresent(this.options.paths.migration);
|
|
1790
|
+
if (!migrationStats) return;
|
|
1791
|
+
const names = (await readBoundedDirectory(this.options.paths.migration))
|
|
1792
|
+
.filter((name) => journalTemporaryPattern.test(name));
|
|
1793
|
+
if (names.length > maximumJournalTemporaryFiles) {
|
|
1794
|
+
throw new Error('AGL migration directory contains too many journal artifacts.');
|
|
1795
|
+
}
|
|
1796
|
+
for (const name of names) {
|
|
1797
|
+
const path = plugins.path.join(this.options.paths.migration, name);
|
|
1798
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
1799
|
+
if (
|
|
1800
|
+
!stats.isFile()
|
|
1801
|
+
|| stats.isSymbolicLink()
|
|
1802
|
+
|| stats.uid !== BigInt(currentUid())
|
|
1803
|
+
|| stats.nlink !== 1n
|
|
1804
|
+
|| Number(stats.mode & 0o777n) !== 0o600
|
|
1805
|
+
|| stats.size > BigInt(maximumJournalBytes)
|
|
1806
|
+
) throw new Error(`AGL journal artifact is unsafe: ${path}`);
|
|
1807
|
+
const identity = identityFromStats(stats);
|
|
1808
|
+
const stable = await lstatIfPresent(path);
|
|
1809
|
+
if (!stable || !identitiesEqual(identityFromStats(stable), identity)) {
|
|
1810
|
+
throw new Error(`AGL journal artifact changed before cleanup: ${path}`);
|
|
1811
|
+
}
|
|
1812
|
+
await plugins.fs.promises.unlink(path);
|
|
1813
|
+
}
|
|
1814
|
+
if (names.length > 0) await syncDirectory(this.options.paths.migration);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
private async unlinkExactFile(
|
|
1818
|
+
pathArg: string,
|
|
1819
|
+
identityArg: IFilesystemIdentity,
|
|
1820
|
+
): Promise<void> {
|
|
1821
|
+
const current = await lstatIfPresent(pathArg);
|
|
1822
|
+
if (!current) return;
|
|
1823
|
+
if (!identitiesEqual(identityFromStats(current), identityArg)) {
|
|
1824
|
+
throw new Error(`AGL migration file identity changed before removal: ${pathArg}`);
|
|
1825
|
+
}
|
|
1826
|
+
await plugins.fs.promises.unlink(pathArg);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
private async cleanupStaleLockTemporaries(): Promise<void> {
|
|
1830
|
+
const parent = plugins.path.dirname(this.lockPath);
|
|
1831
|
+
const base = plugins.path.basename(this.lockPath);
|
|
1832
|
+
const entries = await plugins.fs.promises.readdir(parent, { withFileTypes: true });
|
|
1833
|
+
const temporaryNames = entries
|
|
1834
|
+
.map((entry) => entry.name)
|
|
1835
|
+
.filter((name) => name.startsWith(`${base}.tmp-`));
|
|
1836
|
+
if (temporaryNames.length > maximumLockTemporaryFiles) {
|
|
1837
|
+
throw new Error('Too many AGL migration lock temporary artifacts exist.');
|
|
1838
|
+
}
|
|
1839
|
+
for (const name of temporaryNames) {
|
|
1840
|
+
const match = lockTemporaryPattern.exec(name.slice(base.length));
|
|
1841
|
+
if (!match) throw new Error(`Unknown AGL migration lock artifact: ${name}`);
|
|
1842
|
+
const uid = Number(match[1]);
|
|
1843
|
+
const pid = Number(match[2]);
|
|
1844
|
+
if (!Number.isSafeInteger(uid) || uid !== currentUid() || !Number.isSafeInteger(pid)) {
|
|
1845
|
+
throw new Error(`AGL migration lock temporary artifact binding is invalid: ${name}`);
|
|
1846
|
+
}
|
|
1847
|
+
const path = plugins.path.join(parent, name);
|
|
1848
|
+
const stats = await plugins.fs.promises.lstat(path, { bigint: true });
|
|
1849
|
+
if (
|
|
1850
|
+
!stats.isFile()
|
|
1851
|
+
|| stats.isSymbolicLink()
|
|
1852
|
+
|| stats.uid !== BigInt(currentUid())
|
|
1853
|
+
|| (stats.nlink !== 1n && stats.nlink !== 2n)
|
|
1854
|
+
|| Number(stats.mode & 0o777n) !== 0o600
|
|
1855
|
+
|| stats.size > BigInt(maximumLockBytes)
|
|
1856
|
+
) throw new Error(`AGL migration lock temporary artifact is unsafe: ${path}`);
|
|
1857
|
+
const identity = identityFromStats(stats);
|
|
1858
|
+
let owner: ILockOwner | undefined;
|
|
1859
|
+
try {
|
|
1860
|
+
owner = (await readPrivateLock(path, true))?.owner;
|
|
1861
|
+
} catch (errorArg) {
|
|
1862
|
+
const live = await readControllerProcessIdentity(pid);
|
|
1863
|
+
if (live) continue;
|
|
1864
|
+
}
|
|
1865
|
+
if (owner) {
|
|
1866
|
+
if (owner.uid !== uid || owner.pid !== pid || owner.nonce !== match[3]) {
|
|
1867
|
+
throw new Error(`AGL migration lock temporary artifact owner binding is invalid: ${path}`);
|
|
1868
|
+
}
|
|
1869
|
+
const live = await readControllerProcessIdentity(owner.pid);
|
|
1870
|
+
if (live && live.fingerprint === owner.fingerprint) continue;
|
|
1871
|
+
} else if (await readControllerProcessIdentity(pid)) continue;
|
|
1872
|
+
await this.unlinkExactFile(path, identity);
|
|
1873
|
+
}
|
|
1874
|
+
if (temporaryNames.length > 0) await syncDirectory(parent);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
private async acquireLock(): Promise<{ identity: IFilesystemIdentity; owner: ILockOwner }> {
|
|
1878
|
+
const identity = await readControllerProcessIdentity(process.pid);
|
|
1879
|
+
if (!identity) throw new Error('AGL home migration process identity cannot be verified.');
|
|
1880
|
+
await this.cleanupStaleLockTemporaries();
|
|
1881
|
+
const owner: ILockOwner = {
|
|
1882
|
+
version: 1,
|
|
1883
|
+
pid: process.pid,
|
|
1884
|
+
uid: currentUid(),
|
|
1885
|
+
fingerprint: identity.fingerprint,
|
|
1886
|
+
nonce: plugins.crypto.randomBytes(32).toString('hex'),
|
|
1887
|
+
};
|
|
1888
|
+
const temporaryPath = `${this.lockPath}.tmp-${owner.uid}-${owner.pid}-${owner.nonce}`;
|
|
1889
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1890
|
+
let handle: plugins.fs.promises.FileHandle | undefined;
|
|
1891
|
+
let temporaryIdentity: IFilesystemIdentity | undefined;
|
|
1892
|
+
let publishedIdentity: IFilesystemIdentity | undefined;
|
|
1893
|
+
try {
|
|
1894
|
+
handle = await plugins.fs.promises.open(
|
|
1895
|
+
temporaryPath,
|
|
1896
|
+
plugins.fs.constants.O_WRONLY
|
|
1897
|
+
| plugins.fs.constants.O_CREAT
|
|
1898
|
+
| plugins.fs.constants.O_EXCL
|
|
1899
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
1900
|
+
0o600,
|
|
1901
|
+
);
|
|
1902
|
+
} catch (errorArg) {
|
|
1903
|
+
if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg;
|
|
1904
|
+
}
|
|
1905
|
+
if (handle) {
|
|
1906
|
+
try {
|
|
1907
|
+
temporaryIdentity = identityFromStats(await handle.stat({ bigint: true }));
|
|
1908
|
+
await handle.chmod(0o600);
|
|
1909
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
|
|
1910
|
+
await handle.sync();
|
|
1911
|
+
await handle.close();
|
|
1912
|
+
handle = undefined;
|
|
1913
|
+
const temporary = await readPrivateLock(temporaryPath);
|
|
1914
|
+
if (!temporary || !temporaryIdentity || !identitiesEqual(temporary.identity, temporaryIdentity)) {
|
|
1915
|
+
throw new Error('AGL migration lock temporary owner could not be confirmed.');
|
|
1916
|
+
}
|
|
1917
|
+
try {
|
|
1918
|
+
await plugins.fs.promises.link(temporaryPath, this.lockPath);
|
|
1919
|
+
publishedIdentity = temporaryIdentity;
|
|
1920
|
+
} catch (errorArg) {
|
|
1921
|
+
if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg;
|
|
1922
|
+
}
|
|
1923
|
+
if (publishedIdentity) {
|
|
1924
|
+
const publishedStats = await lstatIfPresent(this.lockPath);
|
|
1925
|
+
if (!publishedStats || !identitiesEqual(identityFromStats(publishedStats), publishedIdentity)) {
|
|
1926
|
+
throw new Error('AGL migration lock publication identity could not be confirmed.');
|
|
1927
|
+
}
|
|
1928
|
+
await this.unlinkExactFile(temporaryPath, temporaryIdentity);
|
|
1929
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
1930
|
+
const published = await readPrivateLock(this.lockPath);
|
|
1931
|
+
if (
|
|
1932
|
+
!published
|
|
1933
|
+
|| !identitiesEqual(published.identity, publishedIdentity)
|
|
1934
|
+
|| JSON.stringify(published.owner) !== JSON.stringify(owner)
|
|
1935
|
+
) throw new Error('AGL migration lock publication owner could not be confirmed.');
|
|
1936
|
+
return { identity: published.identity, owner };
|
|
1937
|
+
}
|
|
1938
|
+
await this.unlinkExactFile(temporaryPath, temporaryIdentity);
|
|
1939
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
1940
|
+
} catch (errorArg) {
|
|
1941
|
+
const cleanupErrors: unknown[] = [];
|
|
1942
|
+
if (handle) {
|
|
1943
|
+
const failedHandle = handle;
|
|
1944
|
+
handle = undefined;
|
|
1945
|
+
try {
|
|
1946
|
+
await failedHandle.close();
|
|
1947
|
+
} catch (cleanupErrorArg) {
|
|
1948
|
+
cleanupErrors.push(cleanupErrorArg);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
if (publishedIdentity) {
|
|
1952
|
+
try {
|
|
1953
|
+
await this.unlinkExactFile(this.lockPath, publishedIdentity);
|
|
1954
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
1955
|
+
} catch (cleanupErrorArg) {
|
|
1956
|
+
cleanupErrors.push(cleanupErrorArg);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (temporaryIdentity) {
|
|
1960
|
+
try {
|
|
1961
|
+
await this.unlinkExactFile(temporaryPath, temporaryIdentity);
|
|
1962
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
1963
|
+
} catch (cleanupErrorArg) {
|
|
1964
|
+
cleanupErrors.push(cleanupErrorArg);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
if (cleanupErrors.length > 0) {
|
|
1968
|
+
throw new AggregateError(
|
|
1969
|
+
[errorArg, ...cleanupErrors],
|
|
1970
|
+
'AGL migration lock acquisition failed and cleanup was incomplete.',
|
|
1971
|
+
{ cause: errorArg },
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
throw errorArg;
|
|
1975
|
+
} finally {
|
|
1976
|
+
if (handle) await handle.close();
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
const existing = await readPrivateLock(this.lockPath);
|
|
1980
|
+
if (!existing) continue;
|
|
1981
|
+
const live = await readControllerProcessIdentity(existing.owner.pid);
|
|
1982
|
+
if (live && live.fingerprint === existing.owner.fingerprint) {
|
|
1983
|
+
throw new Error(`AGL home migration lock is owned by live PID ${existing.owner.pid}.`);
|
|
1984
|
+
}
|
|
1985
|
+
const stable = await readPrivateLock(this.lockPath);
|
|
1986
|
+
if (!stable || !identitiesEqual(stable.identity, existing.identity)) {
|
|
1987
|
+
throw new Error('AGL home migration lock changed during stale-owner inspection.');
|
|
1988
|
+
}
|
|
1989
|
+
await this.unlinkExactFile(this.lockPath, existing.identity);
|
|
1990
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
1991
|
+
}
|
|
1992
|
+
throw new Error('AGL home migration lock could not be acquired.');
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
private async releaseLock(lockArg: {
|
|
1996
|
+
identity: IFilesystemIdentity;
|
|
1997
|
+
owner: ILockOwner;
|
|
1998
|
+
}): Promise<void> {
|
|
1999
|
+
const current = await readPrivateLock(this.lockPath);
|
|
2000
|
+
if (!current || !identitiesEqual(current.identity, lockArg.identity)) {
|
|
2001
|
+
throw new Error('AGL home migration lock identity changed before release.');
|
|
2002
|
+
}
|
|
2003
|
+
if (JSON.stringify(current.owner) !== JSON.stringify(lockArg.owner)) {
|
|
2004
|
+
throw new Error('AGL home migration lock owner changed before release.');
|
|
2005
|
+
}
|
|
2006
|
+
await this.unlinkExactFile(this.lockPath, lockArg.identity);
|
|
2007
|
+
await syncDirectory(plugins.path.dirname(this.lockPath));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
export interface IAGLHomeDataRootMigrationOptions extends IControllerDataDirectoryOptions {
|
|
2012
|
+
listDataWriterProcesses?: () => Promise<readonly IControllerDataWriterProcessRecord[]>;
|
|
2013
|
+
legacyUploadTempDirectory?: string;
|
|
2014
|
+
signal?: AbortSignal;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const resolveDataWriterProcessLister = (
|
|
2018
|
+
optionsArg: IAGLHomeDataRootMigrationOptions,
|
|
2019
|
+
): (() => Promise<readonly IControllerDataWriterProcessRecord[]>) => (
|
|
2020
|
+
optionsArg.listDataWriterProcesses ?? (async () => {
|
|
2021
|
+
const cliPath = await plugins.fs.promises.realpath(
|
|
2022
|
+
plugins.url.fileURLToPath(new URL('../cli.js', import.meta.url)),
|
|
2023
|
+
);
|
|
2024
|
+
return await listControllerDataWriterProcessesForCliPaths([cliPath], {
|
|
2025
|
+
includeInstalledPackagePaths: true,
|
|
2026
|
+
});
|
|
2027
|
+
})
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
const createAGLHomeMigrationRunner = (
|
|
2031
|
+
optionsArg: IAGLHomeDataRootMigrationOptions,
|
|
2032
|
+
listDataWriterProcessesArg: () => Promise<readonly IControllerDataWriterProcessRecord[]>,
|
|
2033
|
+
): AGLHomeMigrationRunner => new AGLHomeMigrationRunner({
|
|
2034
|
+
paths: resolveAGLHomePaths(optionsArg),
|
|
2035
|
+
legacyPaths: resolveAGLLegacyPaths(optionsArg),
|
|
2036
|
+
databaseConfig: readDatabaseConfig(optionsArg),
|
|
2037
|
+
invokerPid: process.pid,
|
|
2038
|
+
listDataWriterProcesses: listDataWriterProcessesArg,
|
|
2039
|
+
...(optionsArg.legacyUploadTempDirectory
|
|
2040
|
+
? { legacyUploadTempDirectory: optionsArg.legacyUploadTempDirectory }
|
|
2041
|
+
: {}),
|
|
2042
|
+
...(optionsArg.signal ? { signal: optionsArg.signal } : {}),
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
const createLegacyDataRootMigrationRunner = (
|
|
2046
|
+
optionsArg: IAGLHomeDataRootMigrationOptions,
|
|
2047
|
+
listDataWriterProcessesArg: () => Promise<readonly IControllerDataWriterProcessRecord[]>,
|
|
2048
|
+
): ControllerDataRootMigrationRunner => {
|
|
2049
|
+
const legacyPaths = resolveAGLLegacyPaths(optionsArg);
|
|
2050
|
+
const environment = optionsArg.environment ?? process.env;
|
|
2051
|
+
if (legacyPaths.development || !legacyPaths.legacyConfigRoot) {
|
|
2052
|
+
throw new Error('Legacy installed data-root migration is unavailable in development mode.');
|
|
2053
|
+
}
|
|
2054
|
+
return new ControllerDataRootMigrationRunner({
|
|
2055
|
+
oldRoot: legacyPaths.legacyConfigRoot,
|
|
2056
|
+
newRoot: legacyPaths.activeDataRoot,
|
|
2057
|
+
databaseConfig: readLegacyV2DatabaseConfig(optionsArg),
|
|
2058
|
+
oldEmbeddedSocketPath: legacyEmbeddedDatabaseSocketPath(
|
|
2059
|
+
plugins.path.join(legacyPaths.legacyConfigRoot, 'smartdb'),
|
|
2060
|
+
),
|
|
2061
|
+
newEmbeddedSocketPath: legacyEmbeddedDatabaseSocketPath(
|
|
2062
|
+
plugins.path.join(legacyPaths.activeDataRoot, 'smartdb'),
|
|
2063
|
+
),
|
|
2064
|
+
invokerPid: process.pid,
|
|
2065
|
+
listDataWriterProcesses: listDataWriterProcessesArg,
|
|
2066
|
+
preserveEmbeddedDataDirectory: Boolean(environment.HARNESS_CONTROLLER_DB_DIR?.trim()),
|
|
2067
|
+
...(optionsArg.signal ? { signal: optionsArg.signal } : {}),
|
|
2068
|
+
});
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
export const preflightAGLHomeDataRootMigration = async (
|
|
2072
|
+
optionsArg: IAGLHomeDataRootMigrationOptions = {},
|
|
2073
|
+
): Promise<void> => {
|
|
2074
|
+
const listDataWriterProcesses = resolveDataWriterProcessLister(optionsArg);
|
|
2075
|
+
const homeMigration = createAGLHomeMigrationRunner(optionsArg, listDataWriterProcesses);
|
|
2076
|
+
await homeMigration.preflight({ allowDataWriters: true });
|
|
2077
|
+
const legacyPaths = resolveAGLLegacyPaths(optionsArg);
|
|
2078
|
+
if (!await homeMigration.hasStarted() && !legacyPaths.development) {
|
|
2079
|
+
await createLegacyDataRootMigrationRunner(optionsArg, listDataWriterProcesses).preflight();
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
export const ensureAGLHomeDataRoot = async (
|
|
2084
|
+
optionsArg: IAGLHomeDataRootMigrationOptions = {},
|
|
2085
|
+
): Promise<IControllerDataRootMigrationResult> => {
|
|
2086
|
+
const paths = resolveAGLHomePaths(optionsArg);
|
|
2087
|
+
const legacyPaths = resolveAGLLegacyPaths(optionsArg);
|
|
2088
|
+
const databaseConfig = readDatabaseConfig(optionsArg);
|
|
2089
|
+
const listDataWriterProcesses = resolveDataWriterProcessLister(optionsArg);
|
|
2090
|
+
const homeMigration = createAGLHomeMigrationRunner(optionsArg, listDataWriterProcesses);
|
|
2091
|
+
if (await homeMigration.isCommitted()) {
|
|
2092
|
+
await homeMigration.preflight({ allowDataWriters: true });
|
|
2093
|
+
return { directoryPath: paths.root, databaseConfig };
|
|
2094
|
+
}
|
|
2095
|
+
if (await homeMigration.hasStarted()) return await homeMigration.run();
|
|
2096
|
+
if (!legacyPaths.development) {
|
|
2097
|
+
await createLegacyDataRootMigrationRunner(optionsArg, listDataWriterProcesses).run();
|
|
2098
|
+
}
|
|
2099
|
+
const result = await homeMigration.run();
|
|
2100
|
+
return { directoryPath: paths.root, databaseConfig: result.databaseConfig ?? databaseConfig };
|
|
2101
|
+
};
|