@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cliOperations.ts +12 -0
- package/bin/restart.ts +66 -6
- package/components/Application.ts +1134 -112
- package/components/OptionsWatcher.ts +368 -102
- package/components/Scope.ts +20 -1
- package/components/componentLoader.ts +43 -4
- package/components/deploymentOperations.ts +4 -1
- package/components/deploymentRecorder.ts +9 -2
- package/components/operations.js +284 -52
- package/components/operationsValidation.js +49 -2
- package/components/packageComponent.ts +25 -1
- package/components/requestRestart.ts +11 -0
- package/config/RootConfigWatcher.ts +191 -37
- package/config/configReadRetry.ts +62 -0
- package/config/configUtils.ts +78 -26
- package/config/parseConfigFile.ts +34 -0
- package/config/readConfigFileSync.ts +44 -0
- package/config/watcherArming.ts +59 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
- package/dist/bin/cliOperations.js +13 -0
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/restart.js +42 -6
- package/dist/bin/restart.js.map +1 -1
- package/dist/components/Application.d.ts +104 -9
- package/dist/components/Application.js +954 -102
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +4 -1
- package/dist/components/OptionsWatcher.js +378 -104
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.js +15 -1
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +35 -3
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentOperations.js +4 -1
- package/dist/components/deploymentOperations.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +4 -2
- package/dist/components/deploymentRecorder.js +1 -0
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/operations.d.ts +28 -0
- package/dist/components/operations.js +263 -54
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +48 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/packageComponent.js +24 -0
- package/dist/components/packageComponent.js.map +1 -1
- package/dist/components/requestRestart.d.ts +1 -0
- package/dist/components/requestRestart.js +7 -0
- package/dist/components/requestRestart.js.map +1 -1
- package/dist/config/RootConfigWatcher.d.ts +2 -0
- package/dist/config/RootConfigWatcher.js +189 -35
- package/dist/config/RootConfigWatcher.js.map +1 -1
- package/dist/config/configReadRetry.d.ts +8 -0
- package/dist/config/configReadRetry.js +62 -0
- package/dist/config/configReadRetry.js.map +1 -0
- package/dist/config/configUtils.d.ts +10 -9
- package/dist/config/configUtils.js +63 -27
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/parseConfigFile.d.ts +4 -0
- package/dist/config/parseConfigFile.js +35 -0
- package/dist/config/parseConfigFile.js.map +1 -0
- package/dist/config/readConfigFileSync.d.ts +1 -0
- package/dist/config/readConfigFileSync.js +47 -0
- package/dist/config/readConfigFileSync.js.map +1 -0
- package/dist/config/watcherArming.d.ts +15 -0
- package/dist/config/watcherArming.js +59 -0
- package/dist/config/watcherArming.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +3 -0
- package/dist/resources/DatabaseTransaction.d.ts +25 -0
- package/dist/resources/DatabaseTransaction.js +224 -18
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +2 -1
- package/dist/resources/LMDBTransaction.js +22 -3
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.js +22 -6
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/RecordEncoder.d.ts +1 -1
- package/dist/resources/RecordEncoder.js +21 -5
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +97 -13
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -0
- package/dist/resources/RocksIndexStore.js +2 -1
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
- package/dist/resources/RocksTransactionLogStore.js +104 -33
- package/dist/resources/RocksTransactionLogStore.js.map +1 -1
- package/dist/resources/Table.d.ts +58 -7
- package/dist/resources/Table.js +1095 -346
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +10 -3
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +170 -0
- package/dist/resources/auditStore.js +457 -11
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/dataLoader.js +3 -4
- package/dist/resources/dataLoader.js.map +1 -1
- package/dist/resources/databases.d.ts +16 -13
- package/dist/resources/databases.js +624 -176
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRegistry.d.ts +5 -0
- package/dist/resources/derivedIndexRegistry.js +68 -0
- package/dist/resources/derivedIndexRegistry.js.map +1 -0
- package/dist/resources/derivedIndexRuntime.d.ts +215 -0
- package/dist/resources/derivedIndexRuntime.js +2027 -0
- package/dist/resources/derivedIndexRuntime.js.map +1 -0
- package/dist/resources/graphql.js +3 -2
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +848 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
- package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
- package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
- package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
- package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
- package/dist/resources/nodeIdMapping.d.ts +5 -0
- package/dist/resources/nodeIdMapping.js +49 -0
- package/dist/resources/nodeIdMapping.js.map +1 -1
- package/dist/resources/recordLock.d.ts +47 -4
- package/dist/resources/recordLock.js +138 -7
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/recordLockCoordinator.d.ts +557 -0
- package/dist/resources/recordLockCoordinator.js +2565 -0
- package/dist/resources/recordLockCoordinator.js.map +1 -0
- package/dist/resources/replayLogs.js +5 -0
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/replicatedApplyFailure.d.ts +16 -0
- package/dist/resources/replicatedApplyFailure.js +63 -0
- package/dist/resources/replicatedApplyFailure.js.map +1 -0
- package/dist/resources/scheduler/scheduler.js +3 -3
- package/dist/resources/scheduler/scheduler.js.map +1 -1
- package/dist/resources/search.d.ts +10 -4
- package/dist/resources/search.js +160 -40
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/tracked.d.ts +5 -1
- package/dist/resources/tracked.js +74 -23
- package/dist/resources/tracked.js.map +1 -1
- package/dist/security/jsLoader.js +6 -4
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +33 -2
- package/dist/server/REST.js.map +1 -1
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +34 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/Headers.d.ts +2 -0
- package/dist/server/serverHelpers/Headers.js +6 -0
- package/dist/server/serverHelpers/Headers.js.map +1 -1
- package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
- package/dist/server/serverHelpers/Request.d.ts +5 -10
- package/dist/server/serverHelpers/Request.js +38 -136
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
- package/dist/server/serverHelpers/contentTypes.js +189 -15
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +96 -17
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/storageReclamation.js +1 -1
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/isolatedApplications.d.ts +47 -0
- package/dist/server/threads/isolatedApplications.js +171 -0
- package/dist/server/threads/isolatedApplications.js.map +1 -0
- package/dist/server/threads/logRotationTransport.d.ts +1 -0
- package/dist/server/threads/logRotationTransport.js +33 -0
- package/dist/server/threads/logRotationTransport.js.map +1 -0
- package/dist/server/threads/manageThreads.d.ts +64 -6
- package/dist/server/threads/manageThreads.js +261 -12
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.d.ts +1 -0
- package/dist/server/threads/socketRouter.js +196 -13
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +30 -7
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +24 -0
- package/dist/utility/errors/hdbError.js +58 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +2 -0
- package/dist/utility/hdbTerms.js +2 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.js +217 -38
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
- package/dist/utility/logging/logGenerationCoordinator.js +184 -0
- package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
- package/dist/utility/logging/logRotation.d.ts +46 -0
- package/dist/utility/logging/logRotation.js +365 -0
- package/dist/utility/logging/logRotation.js.map +1 -0
- package/dist/utility/logging/logRotator.d.ts +1 -1
- package/dist/utility/logging/logRotator.js +172 -92
- package/dist/utility/logging/logRotator.js.map +1 -1
- package/dist/utility/npmUtilities.js +6 -4
- package/dist/utility/npmUtilities.js.map +1 -1
- package/dist/utility/watcherFallback.d.ts +0 -45
- package/dist/utility/watcherFallback.js +1 -125
- package/dist/utility/watcherFallback.js.map +1 -1
- package/dist/validation/configValidator.js +6 -3
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +6 -0
- package/json/systemSchema.json +3 -0
- package/npm-shrinkwrap.json +131 -41
- package/package.json +10 -3
- package/resources/DESIGN.md +124 -19
- package/resources/DatabaseTransaction.ts +230 -17
- package/resources/LMDBTransaction.ts +21 -3
- package/resources/PrimaryRocksDatabase.ts +20 -7
- package/resources/RecordEncoder.ts +25 -5
- package/resources/Resource.ts +97 -13
- package/resources/ResourceInterface.ts +8 -0
- package/resources/RocksIndexStore.ts +2 -1
- package/resources/RocksTransactionLogStore.ts +111 -31
- package/resources/Table.ts +1224 -393
- package/resources/analytics/write.ts +10 -3
- package/resources/auditStore.ts +460 -11
- package/resources/dataLoader.ts +3 -4
- package/resources/databases.ts +610 -146
- package/resources/derivedIndexRegistry.ts +56 -0
- package/resources/derivedIndexRuntime.ts +2292 -0
- package/resources/graphql.ts +3 -2
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +905 -46
- package/resources/indexes/hnswDerivedIndex.ts +531 -0
- package/resources/indexes/hnswPlaneBinding.ts +174 -0
- package/resources/nodeIdMapping.ts +50 -0
- package/resources/recordLock.ts +173 -7
- package/resources/recordLockCoordinator.ts +3043 -0
- package/resources/replayLogs.ts +5 -0
- package/resources/replicatedApplyFailure.ts +77 -0
- package/resources/scheduler/scheduler.ts +4 -4
- package/resources/search.ts +169 -49
- package/resources/tracked.ts +73 -22
- package/security/jsLoader.ts +6 -4
- package/server/DESIGN.md +11 -0
- package/server/REST.ts +36 -3
- package/server/http.ts +34 -2
- package/server/serverHelpers/Headers.ts +5 -1
- package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
- package/server/serverHelpers/Request.ts +33 -131
- package/server/serverHelpers/contentTypes.ts +188 -15
- package/server/serverHelpers/serverUtilities.ts +143 -24
- package/server/storageReclamation.ts +2 -2
- package/server/threads/isolatedApplications.ts +157 -0
- package/server/threads/logRotationTransport.ts +40 -0
- package/server/threads/manageThreads.js +254 -12
- package/server/threads/socketRouter.ts +217 -11
- package/server/threads/threadServer.js +30 -7
- package/studio/web/assets/{Chat-BnCBegQz.js → Chat-D3j-1yY1.js} +1 -1
- package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-BxJGYcfB.js} +3 -3
- package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-CT55oWOe.js} +1 -1
- package/studio/web/assets/{applications-DHxGi7JH.js → applications-D9Ct9_vm.js} +1 -1
- package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-DV8H7VwA.js} +1 -1
- package/studio/web/assets/{editor-DNcRHK54.js → editor-uatc0unt.js} +1 -1
- package/studio/web/assets/{html-Bdssedlg.js → html-Bm6D6paN.js} +1 -1
- package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CEn7tpLG.js} +1 -1
- package/studio/web/assets/{index-D6sxmFLR.js → index-BIXW6Pu4.js} +5 -5
- package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-UI7L-Vrk.js} +1 -1
- package/studio/web/assets/{javascript-B8meVSTH.js → javascript-CJ0G3AFZ.js} +1 -1
- package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-DQADAYEa.js} +1 -1
- package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-CAQJXWcI.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
- package/studio/web/assets/{notifications-CQf18QKb.js → notifications-BbxTU6Aw.js} +1 -1
- package/studio/web/assets/{notifications-CvZivSbh.js → notifications-Cvb3P1lB.js} +1 -1
- package/studio/web/assets/{profile-DdOwtntb.js → profile-Yyb7gsvL.js} +1 -1
- package/studio/web/assets/{regions-n69fwagr.js → regions-OgjGHlU5.js} +1 -1
- package/studio/web/assets/{register-PfWTCXWB.js → register-6qwNEOY3.js} +2 -2
- package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BilDMtgB.js} +1 -1
- package/studio/web/assets/{setup-CUx_aUDl.js → setup-J6qJ7OIU.js} +2 -2
- package/studio/web/assets/{status-D7BVKqX9.js → status-0RWGcfyD.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-BIn-vErT.js} +1 -1
- package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-DgUXku4d.js} +1 -1
- package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-C9orXcsM.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-BEoXXbUB.js} +1 -1
- package/studio/web/assets/{workers-pR3jRY9D.js → workers-JVzSDmgx.js} +1 -1
- package/studio/web/assets/{xml-2iRnMhQO.js → xml-Cq-S8S4X.js} +1 -1
- package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-sfoRdh1M.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +54 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/logging/harper_logger.ts +209 -30
- package/utility/logging/logGenerationCoordinator.ts +196 -0
- package/utility/logging/logRotation.ts +367 -0
- package/utility/logging/logRotator.ts +196 -91
- package/utility/npmUtilities.ts +6 -4
- package/utility/watcherFallback.ts +0 -122
- package/validation/configValidator.ts +6 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Logger } from '../utility/logging/logger.ts';
|
|
2
2
|
import { getConfigObj, getConfigValue, getConfigPath } from '../config/configUtils.ts';
|
|
3
3
|
import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
|
|
4
|
+
import { ClientError } from '../utility/errors/hdbError.ts';
|
|
4
5
|
import logger, { errorForLog } from '../utility/logging/harper_logger.ts';
|
|
5
6
|
import { broadcastDeployStart, broadcastDeployEnd } from './deployLifecycle.ts';
|
|
6
7
|
import { ComponentPreparationLockTimeoutError, withComponentPreparationLock } from './componentPreparationLock.ts';
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
|
|
21
22
|
import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
|
|
22
23
|
|
|
23
|
-
import { basename, dirname, extname, isAbsolute, join, relative, win32 } from 'node:path';
|
|
24
|
+
import { basename, dirname, extname, isAbsolute, join, relative, sep, win32 } from 'node:path';
|
|
24
25
|
import {
|
|
25
26
|
access,
|
|
26
27
|
chmod,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
readdir,
|
|
34
35
|
readFile,
|
|
35
36
|
readlink,
|
|
37
|
+
realpath,
|
|
36
38
|
rename,
|
|
37
39
|
rmdir,
|
|
38
40
|
rm,
|
|
@@ -84,6 +86,8 @@ interface ApplicationConfig {
|
|
|
84
86
|
* Per-application globals are a property of thread-level isolation, not of branching.
|
|
85
87
|
*/
|
|
86
88
|
branchedDatabases?: string[] | true;
|
|
89
|
+
/** Run in a worker thread of its own that loads no other application. */
|
|
90
|
+
isolated?: boolean;
|
|
87
91
|
// an application config can have other arbitrary properties
|
|
88
92
|
[key: string]: unknown;
|
|
89
93
|
}
|
|
@@ -218,6 +222,15 @@ export function assertApplicationConfig(
|
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
224
|
assertBranchedDatabases(applicationName, applicationConfig.branchedDatabases);
|
|
225
|
+
assertIsolationConfig(applicationName, applicationConfig.isolated);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function assertIsolationConfig(applicationName: string, isolated: unknown): void {
|
|
229
|
+
if (isolated !== undefined && typeof isolated !== 'boolean') {
|
|
230
|
+
throw new TypeError(
|
|
231
|
+
`Invalid 'isolated' for application ${applicationName}: expected a boolean, got ${typeof isolated}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
221
234
|
}
|
|
222
235
|
|
|
223
236
|
/**
|
|
@@ -1032,7 +1045,134 @@ const CANDIDATE_COMPONENT_FILE = '.component';
|
|
|
1032
1045
|
// well-formed. Workers cannot infer that case: a well-formed journal is indistinguishable from one belonging
|
|
1033
1046
|
// to a deploy in flight, so without a record they would treat an unsettled component as healthy and load it.
|
|
1034
1047
|
const UNSETTLED_MARKER = '.unsettled';
|
|
1048
|
+
// Everything the build decided that a later activation cannot re-derive: the root-config entry to publish,
|
|
1049
|
+
// whether the installation is opaque to metadata comparison, and the isolation intent that was admitted.
|
|
1050
|
+
// Written before `.complete`, so the marker vouches for it. An OPTIONAL record would not do: it could not
|
|
1051
|
+
// distinguish a payload build, which owns no root config, from a package build whose record was lost.
|
|
1052
|
+
const CANDIDATE_ARTIFACT_FILE = '.artifact.json';
|
|
1035
1053
|
const ACTIVATION_JOURNAL_VERSION = 1;
|
|
1054
|
+
const ARTIFACT_DESCRIPTOR_VERSION = 1;
|
|
1055
|
+
const DEFAULT_STAGING_RETENTION_MAX_COUNT = 5;
|
|
1056
|
+
|
|
1057
|
+
/** `deployment_stagingRetention_maxCount`; 0 keeps none. Only a number or numeric string counts, so `true`/`[]`/blank cannot become "keep nothing". */
|
|
1058
|
+
export function getStagingRetentionMaxCount(): number {
|
|
1059
|
+
const configured = getConfigValue(CONFIG_PARAMS.DEPLOYMENT_STAGINGRETENTION_MAXCOUNT);
|
|
1060
|
+
if (typeof configured !== 'number' && typeof configured !== 'string') return DEFAULT_STAGING_RETENTION_MAX_COUNT;
|
|
1061
|
+
if (typeof configured === 'string' && configured.trim() === '') return DEFAULT_STAGING_RETENTION_MAX_COUNT;
|
|
1062
|
+
const parsed = Number(configured);
|
|
1063
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : DEFAULT_STAGING_RETENTION_MAX_COUNT;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
type DormantBuild = {
|
|
1067
|
+
deploymentDirPath: string;
|
|
1068
|
+
deploymentId: string;
|
|
1069
|
+
completedAt: number;
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
async function presentOrAbsent(path: string): Promise<import('node:fs').Stats | undefined> {
|
|
1073
|
+
return lstat(path).catch((error: NodeJS.ErrnoException) => {
|
|
1074
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
1075
|
+
throw error;
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* A dormant build: complete, tree present, no journal. Activation writes `.complete` moments before its
|
|
1081
|
+
* journal under the owner's preparation lock, so only a read under that lock is a verdict. A stale
|
|
1082
|
+
* `.unsettled` makes it residue instead, since only removing the directory clears that marker for workers.
|
|
1083
|
+
* Only ENOENT is absence; any other read error propagates so the caller preserves the entry.
|
|
1084
|
+
*/
|
|
1085
|
+
async function dormantBuildAt(deploymentDirPath: string, owner: string): Promise<DormantBuild | undefined> {
|
|
1086
|
+
const complete = await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
|
|
1087
|
+
if (!complete) return undefined;
|
|
1088
|
+
if (await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) return undefined;
|
|
1089
|
+
const tree = await presentOrAbsent(join(deploymentDirPath, owner));
|
|
1090
|
+
if (!tree || !(tree.isDirectory() || tree.isSymbolicLink())) return undefined;
|
|
1091
|
+
return { deploymentDirPath, deploymentId: basename(deploymentDirPath), completedAt: complete.mtimeMs };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Remove the oldest dormant builds beyond `maxCount`. The caller must hold the component's preparation lock;
|
|
1096
|
+
* every catalogued build is re-derived under it before the kept set is chosen, so a catalog read unlocked
|
|
1097
|
+
* cannot hold or miss a slot. Never throws: a failure must neither fail a component closed nor replace a
|
|
1098
|
+
* deploy's own error.
|
|
1099
|
+
*
|
|
1100
|
+
* `pinnedDeploymentId` is never evicted. A delayed activation runs this preamble under the same lock it is
|
|
1101
|
+
* about to activate under, so without the pin retention would delete the artifact the request named —
|
|
1102
|
+
* immediately, when the knob is `0`. The pin is applied after the kept set is chosen, so a pinned build in
|
|
1103
|
+
* the eviction tail leaves `maxCount + 1` on disk for the life of the request; the next preamble that does
|
|
1104
|
+
* not pin it brings the count back down.
|
|
1105
|
+
*/
|
|
1106
|
+
export async function pruneDormantBuilds(
|
|
1107
|
+
componentName: string,
|
|
1108
|
+
builds: DormantBuild[],
|
|
1109
|
+
maxCount: number,
|
|
1110
|
+
pinnedDeploymentId?: string
|
|
1111
|
+
): Promise<void> {
|
|
1112
|
+
const current: DormantBuild[] = [];
|
|
1113
|
+
for (const build of builds) {
|
|
1114
|
+
try {
|
|
1115
|
+
const fresh = await dormantBuildAt(build.deploymentDirPath, componentName);
|
|
1116
|
+
if (fresh && !(await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)))) current.push(fresh);
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
logger.warn(
|
|
1119
|
+
`Leaving deploy staging ${build.deploymentDirPath} out of retention; it could not be read:`,
|
|
1120
|
+
errorForLog(error)
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const evictions = current
|
|
1125
|
+
.sort(
|
|
1126
|
+
(left, right) =>
|
|
1127
|
+
right.completedAt - left.completedAt ||
|
|
1128
|
+
(left.deploymentId < right.deploymentId ? -1 : left.deploymentId > right.deploymentId ? 1 : 0)
|
|
1129
|
+
)
|
|
1130
|
+
.slice(Math.max(0, maxCount))
|
|
1131
|
+
.filter((build) => build.deploymentId !== pinnedDeploymentId);
|
|
1132
|
+
for (const build of evictions) {
|
|
1133
|
+
try {
|
|
1134
|
+
await rm(build.deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
1135
|
+
logger.debug?.(
|
|
1136
|
+
`Pruned dormant staged build ${build.deploymentId} of ${componentName} beyond deployment_stagingRetention_maxCount=${maxCount}`
|
|
1137
|
+
);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
logger.warn(
|
|
1140
|
+
`Could not prune dormant staged build ${build.deploymentDirPath} of ${componentName}; it remains beyond ` +
|
|
1141
|
+
`deployment_stagingRetention_maxCount=${maxCount}:`,
|
|
1142
|
+
errorForLog(error)
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Every dormant build a component owns; an unreadable directory is left out and logged. */
|
|
1149
|
+
async function dormantBuildsOf(componentsRootDirPath: string, componentName: string): Promise<DormantBuild[]> {
|
|
1150
|
+
const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
|
|
1151
|
+
let deployments;
|
|
1152
|
+
try {
|
|
1153
|
+
deployments = await readdir(stagingRoot, { withFileTypes: true });
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
const builds: DormantBuild[] = [];
|
|
1159
|
+
for (const deployment of deployments) {
|
|
1160
|
+
if (!deployment.isDirectory()) continue;
|
|
1161
|
+
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
1162
|
+
try {
|
|
1163
|
+
if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
|
|
1164
|
+
if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) continue;
|
|
1165
|
+
const build = await dormantBuildAt(deploymentDirPath, componentName);
|
|
1166
|
+
if (build) builds.push(build);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
logger.warn(
|
|
1169
|
+
`Leaving deploy staging ${deploymentDirPath} out of retention; it could not be read:`,
|
|
1170
|
+
errorForLog(error)
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return builds;
|
|
1175
|
+
}
|
|
1036
1176
|
|
|
1037
1177
|
/**
|
|
1038
1178
|
* Best-effort fsync of a directory. Best-effort by necessity — Node cannot fsync a directory on Windows —
|
|
@@ -1073,6 +1213,62 @@ async function syncRenameParents(fromPath: string, toPath: string): Promise<void
|
|
|
1073
1213
|
for (const parent of parents) await syncDirectory(parent);
|
|
1074
1214
|
}
|
|
1075
1215
|
|
|
1216
|
+
// Deliberately NOT `EEXIST`/`ENOTEMPTY`/`ENOTDIR`/`EISDIR`: those say the destination exists, which
|
|
1217
|
+
// nothing here clears between attempts, so waiting on them would only delay reporting a tree something
|
|
1218
|
+
// recreated — the case `settleInterruptedActivation` fails closed rather than guessing.
|
|
1219
|
+
// `rollbackExtractedDirectory` does retry them, because its placeholder logic repairs the destination.
|
|
1220
|
+
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
1221
|
+
const RENAME_RETRY_BUDGET_MS = 5000;
|
|
1222
|
+
const RENAME_RETRY_INITIAL_DELAY_MS = 10;
|
|
1223
|
+
const RENAME_RETRY_MAX_DELAY_MS = 500;
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Rename, waiting out a holder that has not let go yet — on Windows a rename is refused outright while
|
|
1227
|
+
* anything still has a handle in the source tree.
|
|
1228
|
+
*
|
|
1229
|
+
* `onBackoff` replaces the sleep between attempts; `deadline` lets a rename it performs share this
|
|
1230
|
+
* call's budget instead of opening its own.
|
|
1231
|
+
*/
|
|
1232
|
+
async function renameThroughTransientHolder(
|
|
1233
|
+
fromPath: string,
|
|
1234
|
+
toPath: string,
|
|
1235
|
+
options: { onBackoff?: (delayMs: number, deadline: number) => Promise<void>; deadline?: number } = {}
|
|
1236
|
+
): Promise<void> {
|
|
1237
|
+
const deadline = options.deadline ?? performance.now() + RENAME_RETRY_BUDGET_MS;
|
|
1238
|
+
let delayMs = RENAME_RETRY_INITIAL_DELAY_MS;
|
|
1239
|
+
for (let attempts = 1; ; attempts++) {
|
|
1240
|
+
try {
|
|
1241
|
+
await rename(fromPath, toPath);
|
|
1242
|
+
if (attempts > 1) {
|
|
1243
|
+
logger.warn(`Renamed ${fromPath} to ${toPath} only on attempt ${attempts}; something was holding it`);
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
const code = (error as NodeJS.ErrnoException).code ?? '';
|
|
1248
|
+
if (!TRANSIENT_RENAME_CODES.has(code)) throw error;
|
|
1249
|
+
if (performance.now() >= deadline) {
|
|
1250
|
+
// Which side was still there separates a holder on the source from a destination something
|
|
1251
|
+
// recreated, and neither survives on the rethrown error. A failed probe reports its own code:
|
|
1252
|
+
// an `EPERM` reading the destination is itself evidence, and calling it absent would send the
|
|
1253
|
+
// next investigation the wrong way.
|
|
1254
|
+
const state = async (path: string) =>
|
|
1255
|
+
lstat(path).then(
|
|
1256
|
+
() => 'present',
|
|
1257
|
+
(probeError) => (probeError as NodeJS.ErrnoException)?.code ?? 'unreadable'
|
|
1258
|
+
);
|
|
1259
|
+
logger.warn(
|
|
1260
|
+
`Could not rename ${fromPath} to ${toPath}: ${code} after ${attempts} attempts ` +
|
|
1261
|
+
`(source ${await state(fromPath)}, destination ${await state(toPath)})`
|
|
1262
|
+
);
|
|
1263
|
+
throw error;
|
|
1264
|
+
}
|
|
1265
|
+
if (options.onBackoff) await options.onBackoff(delayMs, deadline);
|
|
1266
|
+
else await delay(delayMs);
|
|
1267
|
+
delayMs = Math.min(delayMs * 2, RENAME_RETRY_MAX_DELAY_MS);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1076
1272
|
/**
|
|
1077
1273
|
* Write a control file so its final name NEVER exists with partial contents. Opening the final path with
|
|
1078
1274
|
* `wx` publishes the directory entry before anything is written, so a crash in between leaves a zero-byte
|
|
@@ -1116,6 +1312,181 @@ function activationJournalPath(componentDirPath: string, deploymentId: string):
|
|
|
1116
1312
|
return join(candidateDeploymentDirPath(componentDirPath, deploymentId), ACTIVATION_JOURNAL);
|
|
1117
1313
|
}
|
|
1118
1314
|
|
|
1315
|
+
function candidateArtifactFilePath(componentDirPath: string, deploymentId: string): string {
|
|
1316
|
+
return join(candidateDeploymentDirPath(componentDirPath, deploymentId), CANDIDATE_ARTIFACT_FILE);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
export type ArtifactDescriptor = {
|
|
1320
|
+
v: number;
|
|
1321
|
+
component: string;
|
|
1322
|
+
/** The root-config entry to publish with the activation; `null` for a payload build, which owns none. */
|
|
1323
|
+
rootConfig: Record<string, unknown> | null;
|
|
1324
|
+
installationIsOpaque: boolean;
|
|
1325
|
+
isolated: boolean;
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Read and fully validate a staged artifact's descriptor. Every field is checked, not just the ones the
|
|
1330
|
+
* caller happens to use: a descriptor is activation input for a build this process did not make, possibly
|
|
1331
|
+
* not even on this node, so a partially-checked one is a way to activate under someone else's intent.
|
|
1332
|
+
* Absence is `undefined`; anything present but unusable throws, because a staged artifact that cannot
|
|
1333
|
+
* describe itself must be refused rather than activated under defaults.
|
|
1334
|
+
*/
|
|
1335
|
+
async function readArtifactDescriptor(
|
|
1336
|
+
deploymentDirPath: string,
|
|
1337
|
+
componentName: string
|
|
1338
|
+
): Promise<ArtifactDescriptor | undefined> {
|
|
1339
|
+
const descriptorPath = join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE);
|
|
1340
|
+
// The artifact the caller named exists and is theirs, but does not describe a build this can activate —
|
|
1341
|
+
// a conflict with what is on disk, not a server fault.
|
|
1342
|
+
const unusable = (message: string) => new ClientError(message, 409);
|
|
1343
|
+
const raw = await readFile(descriptorPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
|
|
1344
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
1345
|
+
throw error;
|
|
1346
|
+
});
|
|
1347
|
+
if (raw === undefined) return undefined;
|
|
1348
|
+
let parsed: any;
|
|
1349
|
+
try {
|
|
1350
|
+
parsed = JSON.parse(raw);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
throw unusable(`Artifact descriptor ${descriptorPath} is not readable JSON: ${errorMessage(error)}`);
|
|
1353
|
+
}
|
|
1354
|
+
if (!parsed || typeof parsed !== 'object' || parsed.v !== ARTIFACT_DESCRIPTOR_VERSION) {
|
|
1355
|
+
throw unusable(`Artifact descriptor ${descriptorPath} is version ${parsed?.v}, which this build cannot activate`);
|
|
1356
|
+
}
|
|
1357
|
+
if (!isJoinableComponentName(parsed.component) || parsed.component !== componentName) {
|
|
1358
|
+
throw unusable(
|
|
1359
|
+
`Artifact descriptor ${descriptorPath} names component '${parsed.component}', not '${componentName}'`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (typeof parsed.installationIsOpaque !== 'boolean' || typeof parsed.isolated !== 'boolean') {
|
|
1363
|
+
throw unusable(`Artifact descriptor ${descriptorPath} does not record its build's runtime decisions`);
|
|
1364
|
+
}
|
|
1365
|
+
if (parsed.rootConfig !== null && (typeof parsed.rootConfig !== 'object' || Array.isArray(parsed.rootConfig))) {
|
|
1366
|
+
throw unusable(`Artifact descriptor ${descriptorPath} does not record a root-config entry or its absence`);
|
|
1367
|
+
}
|
|
1368
|
+
// Admission reads one field and publication writes the other, so two authorities that disagree would
|
|
1369
|
+
// admit one isolation and then publish the opposite.
|
|
1370
|
+
if (parsed.rootConfig && Boolean(parsed.rootConfig.isolated) !== parsed.isolated) {
|
|
1371
|
+
throw unusable(
|
|
1372
|
+
`Artifact descriptor ${descriptorPath} admits isolated=${parsed.isolated} but publishes ` +
|
|
1373
|
+
`isolated=${Boolean(parsed.rootConfig.isolated)}`
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
return parsed as ArtifactDescriptor;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const SEPARATORS_IN_LINK_TARGETS = process.platform === 'win32' ? /[\\/]/ : /\//;
|
|
1380
|
+
|
|
1381
|
+
// `node_modules/harper` and `node_modules/harperdb` are links the LOADER owns: it points them at the
|
|
1382
|
+
// running install on every non-root component load and repairs them when they are missing or stale. They
|
|
1383
|
+
// are outside the artifact by construction and by design, so they are the one external link a staged
|
|
1384
|
+
// artifact may carry.
|
|
1385
|
+
const LOADER_OWNED_LINKS = new Set(['harper', 'harperdb']);
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Reject a staged artifact that reaches outside itself.
|
|
1389
|
+
*
|
|
1390
|
+
* Certification fsyncs the tree but follows no links, and the post-swap relocation repair deliberately
|
|
1391
|
+
* leaves external targets alone — so a symlink into a directory this artifact does not own is a hole in
|
|
1392
|
+
* "activate exactly the bytes that were certified": the target can be edited, or replaced wholesale,
|
|
1393
|
+
* between the stage and the activation. An immediate deploy is not exposed to this, because certification
|
|
1394
|
+
* and activation happen within one call; the delay is what makes it reachable.
|
|
1395
|
+
*
|
|
1396
|
+
*/
|
|
1397
|
+
async function assertOwnedArtifactTree(
|
|
1398
|
+
candidateDirPath: string,
|
|
1399
|
+
componentName: string,
|
|
1400
|
+
action: 'stage' | 'activate' = 'stage'
|
|
1401
|
+
): Promise<void> {
|
|
1402
|
+
// The operator supplied a component that cannot be staged (400); or the artifact they named exists and is
|
|
1403
|
+
// theirs but is no longer what was certified (409). Neither is a server fault, and both reached the
|
|
1404
|
+
// operations handler as a bare 500 until a live run showed what that looks like to a caller.
|
|
1405
|
+
const refuse = (message: string) => new ClientError(message, action === 'stage' ? 400 : 409);
|
|
1406
|
+
const ownedRoot = await realpath(candidateDirPath);
|
|
1407
|
+
// The loader repairs the component's OWN `node_modules/harper`, not a copy nested inside a dependency,
|
|
1408
|
+
// so only that one path is exempt. Matching the name at any depth would let `dep/node_modules/harper`
|
|
1409
|
+
// point anywhere and still pass.
|
|
1410
|
+
const loaderOwnedDir = join(candidateDirPath, 'node_modules');
|
|
1411
|
+
const walk = async (dirPath: string): Promise<void> => {
|
|
1412
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
1413
|
+
for (const entry of entries) {
|
|
1414
|
+
const entryPath = join(dirPath, entry.name);
|
|
1415
|
+
if (entry.isDirectory()) {
|
|
1416
|
+
await walk(entryPath);
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
// Junctions report as symbolic links here, which is what makes this cover Windows.
|
|
1420
|
+
if (!entry.isSymbolicLink()) continue;
|
|
1421
|
+
if (LOADER_OWNED_LINKS.has(entry.name) && dirPath === loaderOwnedDir) continue;
|
|
1422
|
+
// An unresolvable link is rejected for the same reason a foreign one is: nothing certified what
|
|
1423
|
+
// it will resolve to by the time somebody activates it.
|
|
1424
|
+
const target = await realpath(entryPath).catch(() => undefined);
|
|
1425
|
+
if (target === undefined || (target !== ownedRoot && !target.startsWith(ownedRoot + sep))) {
|
|
1426
|
+
throw refuse(
|
|
1427
|
+
`Cannot ${action} ${componentName}: ${entryPath} links outside the build to ${target ?? 'a missing target'}, ` +
|
|
1428
|
+
`so the bytes activated later would not be the bytes this build certified`
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
// `repairRelocatedDependencyLinks` re-points links after the swap, but it runs PAST THE COMMIT
|
|
1432
|
+
// POINT and can only warn — it logs and continues on a failed re-point, and skips a whole subtree
|
|
1433
|
+
// on EACCES/EMFILE — so a component could go live holding a link to a path that no longer exists
|
|
1434
|
+
// while the operation reports success. Staging fails closed instead. The cost: npm writes absolute
|
|
1435
|
+
// junctions under `node_modules` on Windows for a `file:`/workspace dependency, so such a component
|
|
1436
|
+
// deploys immediately but cannot be staged until its links are relative.
|
|
1437
|
+
const linkTarget = await readlink(entryPath);
|
|
1438
|
+
if (isAbsolute(linkTarget)) {
|
|
1439
|
+
throw refuse(
|
|
1440
|
+
`Cannot ${action} ${componentName}: ${entryPath} names its target inside the build by absolute path ` +
|
|
1441
|
+
`(${linkTarget}), which activation moves. Re-link it relatively — on Windows, npm ` +
|
|
1442
|
+
`writes absolute junctions for 'file:' and workspace dependencies, so those have to be relative ` +
|
|
1443
|
+
`before the component can be staged.`
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
// Where the link ENDS UP is not enough: a target that leaves the candidate and comes back resolves
|
|
1447
|
+
// inside it today and somewhere else once activation renames the tree, because the same relative
|
|
1448
|
+
// expression is then evaluated from `components/<component>/…`. Counting `..` segments does not
|
|
1449
|
+
// catch it either, since an intermediate symlink (`up -> ..`) reduces depth without spelling it.
|
|
1450
|
+
// So every PREFIX of the walk is resolved, with symlinks followed as the filesystem will follow
|
|
1451
|
+
// them, and each one has to still be inside the candidate.
|
|
1452
|
+
let prefix = dirname(entryPath);
|
|
1453
|
+
// Only Windows treats a backslash as a separator. On POSIX it is an ordinary filename character, so
|
|
1454
|
+
// splitting on it there turns a link to the single legal entry `..\asset` — which resolves inside
|
|
1455
|
+
// the candidate and keeps resolving there after relocation — into `..` plus `asset`, and refuses a
|
|
1456
|
+
// component that never left its own tree.
|
|
1457
|
+
for (const segment of linkTarget.split(SEPARATORS_IN_LINK_TARGETS)) {
|
|
1458
|
+
if (segment === '' || segment === '.') continue;
|
|
1459
|
+
prefix = await realpath(join(prefix, segment)).catch(() => join(prefix, segment));
|
|
1460
|
+
if (prefix !== ownedRoot && !prefix.startsWith(ownedRoot + sep)) {
|
|
1461
|
+
throw refuse(
|
|
1462
|
+
`Cannot ${action} ${componentName}: ${entryPath} reaches ${prefix} on its way to ${linkTarget}, ` +
|
|
1463
|
+
`leaving the build — after activation moves the tree that path resolves somewhere else`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
await walk(candidateDirPath);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Record the build's decisions beside the candidate. Called before `.complete`, which vouches for it. */
|
|
1473
|
+
async function writeArtifactDescriptor(
|
|
1474
|
+
componentDirPath: string,
|
|
1475
|
+
deploymentId: string,
|
|
1476
|
+
descriptor: ArtifactDescriptor
|
|
1477
|
+
): Promise<void> {
|
|
1478
|
+
try {
|
|
1479
|
+
await writeControlFileDurably(
|
|
1480
|
+
candidateArtifactFilePath(componentDirPath, deploymentId),
|
|
1481
|
+
JSON.stringify(descriptor)
|
|
1482
|
+
);
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
// An existing descriptor belongs to this same artifact — the id is claimed exclusively, so nothing
|
|
1485
|
+
// else can have written one — which makes this a retry of its own stage rather than a conflict.
|
|
1486
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1119
1490
|
/**
|
|
1120
1491
|
* A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
|
|
1121
1492
|
* Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
|
|
@@ -1170,8 +1541,25 @@ async function readActivationJournal(journalPath: string): Promise<ActivationJou
|
|
|
1170
1541
|
return parsed as ActivationJournal;
|
|
1171
1542
|
}
|
|
1172
1543
|
|
|
1173
|
-
/**
|
|
1544
|
+
/**
|
|
1545
|
+
* The deployment directory holding one candidate build: `<root>/.deploy-staging/<deploymentId>`.
|
|
1546
|
+
*
|
|
1547
|
+
* The id is asserted here rather than only at the request boundary because every caller funnels through
|
|
1548
|
+
* this one join, and the id is now operator-supplied (`deployment_id`) or replication-supplied
|
|
1549
|
+
* (`_deploymentId`). A traversal-bearing id would otherwise direct both the build and its removal outside
|
|
1550
|
+
* `.deploy-staging`.
|
|
1551
|
+
*/
|
|
1174
1552
|
function candidateDeploymentDirPath(componentDirPath: string, deploymentId: string): string {
|
|
1553
|
+
if (
|
|
1554
|
+
typeof deploymentId !== 'string' ||
|
|
1555
|
+
deploymentId.length === 0 ||
|
|
1556
|
+
deploymentId !== basename(deploymentId) ||
|
|
1557
|
+
// `basename` returns these unchanged, so the comparison above admits both.
|
|
1558
|
+
deploymentId === '.' ||
|
|
1559
|
+
deploymentId === '..'
|
|
1560
|
+
) {
|
|
1561
|
+
throw new Error(`Deployment id '${deploymentId}' is not a single path segment`);
|
|
1562
|
+
}
|
|
1175
1563
|
return join(dirname(componentDirPath), DEPLOY_STAGING_DIR, deploymentId);
|
|
1176
1564
|
}
|
|
1177
1565
|
|
|
@@ -1259,6 +1647,109 @@ async function ensureSecureStagingDirectory(stagingDir: string): Promise<void> {
|
|
|
1259
1647
|
}
|
|
1260
1648
|
}
|
|
1261
1649
|
|
|
1650
|
+
/**
|
|
1651
|
+
* Claim a deployment directory for this build, EXCLUSIVELY. The id is the public deployment id, which an
|
|
1652
|
+
* operator can repeat and a redelivered replication can repeat for them, so tolerating an existing
|
|
1653
|
+
* directory would let a replayed stage rewrite the bytes under an existing `.complete` and descriptor —
|
|
1654
|
+
* and a crash mid-rebuild would leave a partial tree that still reads as certified.
|
|
1655
|
+
*
|
|
1656
|
+
* The caller holds the component's preparation lock, which is what makes the EEXIST verdicts sound: no
|
|
1657
|
+
* other preparation of THIS component is running, and a directory belonging to another component is not
|
|
1658
|
+
* this lock's to touch.
|
|
1659
|
+
*/
|
|
1660
|
+
async function claimDeploymentDirectory(deploymentDirPath: string, componentName: string): Promise<void> {
|
|
1661
|
+
// Every refusal below is a conflict over an id that already exists, which is the caller's to resolve by
|
|
1662
|
+
// naming a different one — not a server fault, and not the 500 a bare Error reaches the caller as.
|
|
1663
|
+
const taken = (message: string) => new ClientError(message, 409);
|
|
1664
|
+
try {
|
|
1665
|
+
await mkdir(deploymentDirPath, { mode: 0o700 });
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
1668
|
+
const owner = await candidateComponentName(deploymentDirPath);
|
|
1669
|
+
if (owner !== undefined && owner !== componentName) {
|
|
1670
|
+
throw taken(
|
|
1671
|
+
`Deployment id ${basename(deploymentDirPath)} already holds a build of '${owner}'; a deployment id ` +
|
|
1672
|
+
`names one artifact for its lifetime`
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
// Ownership has to be POSITIVE to reclaim, and EMPTINESS IS NOT A VERDICT. The sidecar below is
|
|
1676
|
+
// written as part of the claim, so a directory naming nobody is another component between its own
|
|
1677
|
+
// mkdir and that write, and a deploy can hold a nearly empty one for minutes while it resolves and
|
|
1678
|
+
// packs. Nothing on disk separates that from a claim that got no further, and the lock that
|
|
1679
|
+
// serializes it is not this one, so only the exclusive create may conclude the id is free: the
|
|
1680
|
+
// directory can also have been discarded by a failed build between the first mkdir and this read,
|
|
1681
|
+
// and another component can claim it in that same gap.
|
|
1682
|
+
if (owner === undefined) {
|
|
1683
|
+
await mkdir(deploymentDirPath, { mode: 0o700 }).catch((retry: NodeJS.ErrnoException) => {
|
|
1684
|
+
if (retry?.code !== 'EEXIST') throw retry;
|
|
1685
|
+
throw taken(
|
|
1686
|
+
`Deployment id ${basename(deploymentDirPath)} is already claimed by a build that has not named its ` +
|
|
1687
|
+
`component yet. If no deploy of any component is in flight, that directory is abandoned and has ` +
|
|
1688
|
+
`to be removed by hand; deploying again without a deployment_id mints a fresh id`
|
|
1689
|
+
);
|
|
1690
|
+
});
|
|
1691
|
+
} else {
|
|
1692
|
+
if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
|
|
1693
|
+
throw taken(
|
|
1694
|
+
`Deployment id ${basename(deploymentDirPath)} already holds a completed build of '${componentName}'; ` +
|
|
1695
|
+
`deploy it with deployment_id, or deploy again to build a new one`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
// This component's own preparation lock serializes the claim and nothing certified it, so nothing
|
|
1699
|
+
// is lost by rebuilding over it.
|
|
1700
|
+
await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
1701
|
+
await mkdir(deploymentDirPath, { mode: 0o700 });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const claimed = await lstat(deploymentDirPath);
|
|
1705
|
+
if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
|
|
1706
|
+
throw new Error(`Component deploy staging path is not a directory: ${deploymentDirPath}`);
|
|
1707
|
+
}
|
|
1708
|
+
if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
|
|
1709
|
+
await chmod(deploymentDirPath, 0o700).catch((error) =>
|
|
1710
|
+
logger.warn(
|
|
1711
|
+
`Could not restrict component deploy staging permissions for ${deploymentDirPath}:`,
|
|
1712
|
+
errorForLog(error)
|
|
1713
|
+
)
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
await publishClaimOwnership(deploymentDirPath, componentName);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Name the component that owns a deployment directory THIS CALL created, and take the directory back if that
|
|
1721
|
+
* cannot be recorded.
|
|
1722
|
+
*
|
|
1723
|
+
* Ownership is published as part of the claim rather than at certification, because resolving and packing can
|
|
1724
|
+
* take minutes and until a tree exists to infer an owner from, a directory answering to nobody is one another
|
|
1725
|
+
* component will take for abandoned. Unattributed is a permanent refusal, though, so a failure here — a full
|
|
1726
|
+
* disk, an EIO on the temp write or its sync — would burn this deployment id for good, every retry refused by
|
|
1727
|
+
* its own wreckage. The removal is scoped to the directory this invocation made, and deliberately not
|
|
1728
|
+
* broadened to one found by EEXIST: that directory may be another claimant's, and removing it is the race the
|
|
1729
|
+
* refusal exists to prevent. Best-effort, because the claim failure is what the caller needs to see.
|
|
1730
|
+
*
|
|
1731
|
+
* `write` is a parameter so the failure is testable without a filesystem that can be made to fail on exactly
|
|
1732
|
+
* this write and nothing else.
|
|
1733
|
+
*/
|
|
1734
|
+
export async function publishClaimOwnership(
|
|
1735
|
+
deploymentDirPath: string,
|
|
1736
|
+
componentName: string,
|
|
1737
|
+
write: (filePath: string, contents: string) => Promise<void> = writeControlFileDurably
|
|
1738
|
+
): Promise<void> {
|
|
1739
|
+
try {
|
|
1740
|
+
await write(join(deploymentDirPath, CANDIDATE_COMPONENT_FILE), componentName);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
await rm(deploymentDirPath, { recursive: true, force: true }).catch((cleanupError) =>
|
|
1743
|
+
logger.warn(
|
|
1744
|
+
`Could not remove the deployment directory ${deploymentDirPath} after failing to publish its ` +
|
|
1745
|
+
`ownership; the id stays unusable until it is removed:`,
|
|
1746
|
+
errorForLog(cleanupError)
|
|
1747
|
+
)
|
|
1748
|
+
);
|
|
1749
|
+
throw error;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1262
1753
|
async function ensureExtractionStagingDirectory(asideStagingDir: string): Promise<void> {
|
|
1263
1754
|
for (const stagingDir of [dirname(asideStagingDir), asideStagingDir]) {
|
|
1264
1755
|
await ensureSecureStagingDirectory(stagingDir);
|
|
@@ -1415,7 +1906,8 @@ async function inProgressAsideRecords(asideStagingDir: string): Promise<string[]
|
|
|
1415
1906
|
}
|
|
1416
1907
|
|
|
1417
1908
|
/**
|
|
1418
|
-
* Settle journaled activations for ONE component,
|
|
1909
|
+
* Settle journaled activations for ONE component, and bound its dormant staged builds, assuming the caller
|
|
1910
|
+
* already holds its preparation lock.
|
|
1419
1911
|
*
|
|
1420
1912
|
* Exists because the journal-first rule has to hold at every entry point, not just startup. A deploy runs
|
|
1421
1913
|
* `recoverOrCleanupStaleExtractionPaths` first. After an activation whose retirement failed, the aside
|
|
@@ -1423,9 +1915,10 @@ async function inProgressAsideRecords(asideStagingDir: string): Promise<string[]
|
|
|
1423
1915
|
* pass refuses to restore against a surviving journal, but refusing is a stalled component; settling first
|
|
1424
1916
|
* is what lets the deploy proceed.
|
|
1425
1917
|
*/
|
|
1426
|
-
async function
|
|
1918
|
+
async function settleStagingForComponent(
|
|
1427
1919
|
componentsRootDirPath: string,
|
|
1428
|
-
componentName: string
|
|
1920
|
+
componentName: string,
|
|
1921
|
+
pinnedDeploymentId?: string
|
|
1429
1922
|
): Promise<void> {
|
|
1430
1923
|
const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
|
|
1431
1924
|
let deployments;
|
|
@@ -1435,6 +1928,7 @@ async function settleJournaledActivationsForComponent(
|
|
|
1435
1928
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1436
1929
|
throw error;
|
|
1437
1930
|
}
|
|
1931
|
+
const dormant: DormantBuild[] = [];
|
|
1438
1932
|
for (const deployment of deployments) {
|
|
1439
1933
|
if (!deployment.isDirectory()) continue;
|
|
1440
1934
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
@@ -1464,13 +1958,27 @@ async function settleJournaledActivationsForComponent(
|
|
|
1464
1958
|
// A journal-less directory is what a SUCCESSFUL settlement leaves when its best-effort sweep fails, so
|
|
1465
1959
|
// this must not fail closed. An unattributable *activation* still does: `readActivationJournal` throws
|
|
1466
1960
|
// on a journal that exists but cannot be read.
|
|
1467
|
-
if (!journal)
|
|
1961
|
+
if (!journal) {
|
|
1962
|
+
if (ownerUnreadable) continue;
|
|
1963
|
+
try {
|
|
1964
|
+
const build = await dormantBuildAt(deploymentDirPath, componentName);
|
|
1965
|
+
if (build) dormant.push(build);
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
logger.warn(
|
|
1968
|
+
`Leaving staged ${componentName} build ${deploymentDirPath} out of retention; it could not be read:`,
|
|
1969
|
+
errorForLog(error)
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1468
1974
|
// The journal decides, not the sidecar. Skipping on an unreadable sidecar alone would leave this
|
|
1469
1975
|
// component's own unsettled activation in place while a new deploy proceeded over it, and an
|
|
1470
1976
|
// activation interrupted before B1 has no rollback record for the restore gate to catch.
|
|
1471
1977
|
if (journal.component !== componentName) continue;
|
|
1472
1978
|
await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
|
|
1473
1979
|
}
|
|
1980
|
+
const maxCount = getStagingRetentionMaxCount();
|
|
1981
|
+
if (dormant.length > maxCount) await pruneDormantBuilds(componentName, dormant, maxCount, pinnedDeploymentId);
|
|
1474
1982
|
}
|
|
1475
1983
|
|
|
1476
1984
|
/**
|
|
@@ -1582,30 +2090,18 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
1582
2090
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return failures;
|
|
1583
2091
|
throw error;
|
|
1584
2092
|
}
|
|
2093
|
+
const dormant = new Map<string, DormantBuild[]>();
|
|
2094
|
+
const catalogue = (owner: string, build: DormantBuild) => {
|
|
2095
|
+
const builds = dormant.get(owner);
|
|
2096
|
+
if (builds) builds.push(build);
|
|
2097
|
+
else dormant.set(owner, [build]);
|
|
2098
|
+
};
|
|
1585
2099
|
|
|
1586
2100
|
for (const deployment of deployments) {
|
|
1587
2101
|
if (!deployment.isDirectory()) continue;
|
|
1588
2102
|
const deploymentDirPath = join(stagingRoot, deployment.name);
|
|
1589
2103
|
const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
|
|
1590
|
-
const fail =
|
|
1591
|
-
const failure = error instanceof Error ? error : new Error(String(error));
|
|
1592
|
-
if (!failures.has(component)) failures.set(component, failure);
|
|
1593
|
-
logger.error(`Could not settle the interrupted activation of ${component}:`, errorForLog(failure));
|
|
1594
|
-
// A DEFERRAL is not a verdict, and only verdicts go on disk. A held lock means a live deploy, which
|
|
1595
|
-
// settles its own journal; a marker written here would outlive that deploy and have
|
|
1596
|
-
// `unsettleableComponentsFromDisk` read it as an authoritative "cannot be settled", failing a
|
|
1597
|
-
// healthy component closed on every worker. The failure is already recorded above, so this thread
|
|
1598
|
-
// still defers — it just leaves nothing behind.
|
|
1599
|
-
if (failure instanceof ComponentPreparationLockTimeoutError) return;
|
|
1600
|
-
// Everything else IS a verdict, recorded so workers reach the same one. An unreadable journal is
|
|
1601
|
-
// self-evident, but a well-formed journal this pass could not settle looks exactly like a deploy
|
|
1602
|
-
// in flight, and a worker would otherwise load the component over state nobody reconciled.
|
|
1603
|
-
// Best-effort: the alternative to a missing marker is today's behavior, not a worse one.
|
|
1604
|
-
await writeFile(join(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch(
|
|
1605
|
-
(markerError) =>
|
|
1606
|
-
logger.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`)
|
|
1607
|
-
);
|
|
1608
|
-
};
|
|
2104
|
+
const fail = (component: string, error: unknown) => recordUnsettled(failures, component, error, deploymentDirPath);
|
|
1609
2105
|
|
|
1610
2106
|
let journal: ActivationJournal | undefined;
|
|
1611
2107
|
try {
|
|
@@ -1665,6 +2161,44 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
1665
2161
|
activationToFail = undefined;
|
|
1666
2162
|
return;
|
|
1667
2163
|
}
|
|
2164
|
+
// Re-classified UNDER the lock: the unlocked read that routed this here can predate the `.complete`
|
|
2165
|
+
// a deploy wrote before dying, and that is a retainable build, not residue.
|
|
2166
|
+
const build = await dormantBuildAt(deploymentDirPath, owner!);
|
|
2167
|
+
if (build) {
|
|
2168
|
+
catalogue(owner!, build);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
// A DESCRIBED artifact carrying a stale verdict is settled, not residue. `fail()` only ever writes
|
|
2172
|
+
// `.unsettled` beside a journal it keeps, so a marker with no journal says settlement finished
|
|
2173
|
+
// and only the marker's own removal was lost — which is exactly what a crash between a dormant
|
|
2174
|
+
// return's two unlinks leaves, and the barrier that orders them cannot run on Windows. Deleting
|
|
2175
|
+
// here would destroy a build somebody staged deliberately, whose payload may already have been
|
|
2176
|
+
// reclaimed, on the strength of a verdict that no longer applies. Clearing the marker is the
|
|
2177
|
+
// idempotent completion of the settlement that wrote it; an undescribed build stays disposable.
|
|
2178
|
+
if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
|
|
2179
|
+
// A fault clearing the marker is not a licence to delete what it is attached to — and not a
|
|
2180
|
+
// licence to say nothing either. The marker survives, every worker fails the component closed
|
|
2181
|
+
// on it, and main reporting success is the split where main serves what every worker refuses.
|
|
2182
|
+
// Failing the component brings main to the workers' verdict instead, the rule the settled tail
|
|
2183
|
+
// follows, and leaves the artifact for the next pass to clear.
|
|
2184
|
+
try {
|
|
2185
|
+
await clearUnsettledVerdict(deploymentDirPath, owner!);
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
activationToFail = owner;
|
|
2188
|
+
throw error;
|
|
2189
|
+
}
|
|
2190
|
+
const settled = await dormantBuildAt(deploymentDirPath, owner!);
|
|
2191
|
+
if (settled) {
|
|
2192
|
+
logger.info?.(
|
|
2193
|
+
`Cleared a stale unsettled verdict from the staged build ${basename(deploymentDirPath)} of ` +
|
|
2194
|
+
`${owner}; its activation was already settled`
|
|
2195
|
+
);
|
|
2196
|
+
catalogue(owner!, settled);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
// Cleared, and still not a retainable build — an incomplete or treeless staged directory. That
|
|
2200
|
+
// is residue like any other, so it falls through to the removal below.
|
|
2201
|
+
}
|
|
1668
2202
|
// Cleanup, not settlement. There was no activation here — this is most often the residue a
|
|
1669
2203
|
// SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
|
|
1670
2204
|
// make anything unsettled, and recording it would refuse a live component on every worker
|
|
@@ -1689,6 +2223,24 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
1689
2223
|
);
|
|
1690
2224
|
continue;
|
|
1691
2225
|
}
|
|
2226
|
+
// Catalogued WITHOUT the lock and left alone: a retained build is never removed here, so a per-directory
|
|
2227
|
+
// lock would recur on every pass and contend with sibling threads for a component nothing is deploying.
|
|
2228
|
+
if (owner) {
|
|
2229
|
+
let build: DormantBuild | undefined;
|
|
2230
|
+
try {
|
|
2231
|
+
build = await dormantBuildAt(deploymentDirPath, owner);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
logger.warn(
|
|
2234
|
+
`Leaving deploy staging ${deploymentDirPath} in place; it could not be read:`,
|
|
2235
|
+
errorForLog(error)
|
|
2236
|
+
);
|
|
2237
|
+
continue;
|
|
2238
|
+
}
|
|
2239
|
+
if (build) {
|
|
2240
|
+
catalogue(owner, build);
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
1692
2244
|
// Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
|
|
1693
2245
|
// abort the entire scan, leaving every later deployment unsettled and unmarked.
|
|
1694
2246
|
try {
|
|
@@ -1755,6 +2307,120 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
|
|
|
1755
2307
|
await fail(journal.component, error);
|
|
1756
2308
|
}
|
|
1757
2309
|
}
|
|
2310
|
+
|
|
2311
|
+
const maxCount = getStagingRetentionMaxCount();
|
|
2312
|
+
for (const [owner, builds] of dormant) {
|
|
2313
|
+
for (const [component, error] of await reconcileDormantBuilds(componentsRootDirPath, owner, builds, maxCount)) {
|
|
2314
|
+
if (!failures.has(component)) failures.set(component, error);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
return failures;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* Record a settlement failure against a component. A lock TIMEOUT is a deferral, not a verdict, and only
|
|
2322
|
+
* verdicts go on disk: a held lock means a live deploy, which settles its own journal, and a marker written
|
|
2323
|
+
* here would outlive it and have `unsettleableComponentsFromDisk` fail a healthy component closed on every
|
|
2324
|
+
* worker. Everything else is written so workers reach the same verdict — a well-formed journal this pass
|
|
2325
|
+
* could not settle looks exactly like a deploy in flight otherwise. Marker write is best-effort.
|
|
2326
|
+
*/
|
|
2327
|
+
async function recordUnsettled(
|
|
2328
|
+
failures: Map<string, Error>,
|
|
2329
|
+
component: string,
|
|
2330
|
+
error: unknown,
|
|
2331
|
+
deploymentDirPath: string
|
|
2332
|
+
): Promise<void> {
|
|
2333
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
2334
|
+
if (!failures.has(component)) failures.set(component, failure);
|
|
2335
|
+
logger.error(`Could not settle the interrupted activation of ${component}:`, errorForLog(failure));
|
|
2336
|
+
if (failure instanceof ComponentPreparationLockTimeoutError) return;
|
|
2337
|
+
await writeFile(join(deploymentDirPath, UNSETTLED_MARKER), failure.message, { mode: 0o600 }).catch((markerError) =>
|
|
2338
|
+
logger.warn(`Could not record the unsettled activation of ${component}: ${errorMessage(markerError)}`)
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Finish one owner's catalogued dormant builds after the scan. The catalog was read without the lock, so a
|
|
2344
|
+
* deploy may have published a journal into one of these directories since — and if it then died mid-swap,
|
|
2345
|
+
* only settlement brings the component back. So: settle any journal that appeared, then bound what is still
|
|
2346
|
+
* dormant. The lock is taken only when there is something to do; a lock a live deploy holds is the same
|
|
2347
|
+
* deferral the residue branch records.
|
|
2348
|
+
*/
|
|
2349
|
+
export async function reconcileDormantBuilds(
|
|
2350
|
+
componentsRootDirPath: string,
|
|
2351
|
+
owner: string,
|
|
2352
|
+
builds: DormantBuild[],
|
|
2353
|
+
maxCount: number
|
|
2354
|
+
): Promise<Map<string, Error>> {
|
|
2355
|
+
const failures = new Map<string, Error>();
|
|
2356
|
+
let journaled: DormantBuild | undefined;
|
|
2357
|
+
for (const build of builds) {
|
|
2358
|
+
// Anything but a clean ENOENT means "read it properly, under the lock".
|
|
2359
|
+
const appeared = await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)).then(
|
|
2360
|
+
(stats) => stats !== undefined,
|
|
2361
|
+
() => true
|
|
2362
|
+
);
|
|
2363
|
+
if (appeared) {
|
|
2364
|
+
journaled = build;
|
|
2365
|
+
break;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
if (!journaled && builds.length <= maxCount) return failures;
|
|
2369
|
+
try {
|
|
2370
|
+
await withComponentPreparationLock(
|
|
2371
|
+
join(componentsRootDirPath, owner),
|
|
2372
|
+
async () => {
|
|
2373
|
+
const stillDormant: DormantBuild[] = [];
|
|
2374
|
+
for (const build of builds) {
|
|
2375
|
+
let journal: ActivationJournal | undefined;
|
|
2376
|
+
try {
|
|
2377
|
+
journal = await readActivationJournal(join(build.deploymentDirPath, ACTIVATION_JOURNAL));
|
|
2378
|
+
} catch (error) {
|
|
2379
|
+
await recordUnsettled(failures, owner, error, build.deploymentDirPath);
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
if (!journal) {
|
|
2383
|
+
stillDormant.push(build);
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
2386
|
+
// The lock held here is the SIDECAR owner's, as in the residue branch: a journal naming someone
|
|
2387
|
+
// else is not settled under it, and both names are failed.
|
|
2388
|
+
const splitNames = splitAttributionOwners(journal.component, owner);
|
|
2389
|
+
if (splitNames) {
|
|
2390
|
+
const split = splitAttributionError(build.deploymentDirPath, journal.component, splitNames[0]);
|
|
2391
|
+
for (const name of splitNames) await recordUnsettled(failures, name, split, build.deploymentDirPath);
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
try {
|
|
2395
|
+
await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
|
|
2396
|
+
} catch (error) {
|
|
2397
|
+
await recordUnsettled(failures, journal.component, error, build.deploymentDirPath);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
if (stillDormant.length > maxCount) await pruneDormantBuilds(owner, stillDormant, maxCount);
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
purpose: 'activation-recovery',
|
|
2404
|
+
...RECOVERY_LOCK_WAIT,
|
|
2405
|
+
isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
|
|
2406
|
+
}
|
|
2407
|
+
);
|
|
2408
|
+
} catch (error) {
|
|
2409
|
+
// With a journal in view this is an activation that could not be settled — recorded exactly as the
|
|
2410
|
+
// scan records one it saw directly (a timeout defers, anything else is a verdict). Without one it is
|
|
2411
|
+
// hygiene that could not run, unless a live deploy holds the lock, which defers as everywhere else.
|
|
2412
|
+
if (journaled) {
|
|
2413
|
+
await recordUnsettled(failures, owner, error, journaled.deploymentDirPath);
|
|
2414
|
+
return failures;
|
|
2415
|
+
}
|
|
2416
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
2417
|
+
if (failure instanceof ComponentPreparationLockTimeoutError) {
|
|
2418
|
+
if (!failures.has(owner)) failures.set(owner, failure);
|
|
2419
|
+
logger.info?.(`Deferred pruning the dormant staged builds of ${owner}: a deploy holds its lock`);
|
|
2420
|
+
} else {
|
|
2421
|
+
logger.warn(`Could not prune the dormant staged builds of ${owner}:`, errorForLog(failure));
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
1758
2424
|
return failures;
|
|
1759
2425
|
}
|
|
1760
2426
|
|
|
@@ -1786,6 +2452,28 @@ async function sweepAsideRecords(
|
|
|
1786
2452
|
}
|
|
1787
2453
|
}
|
|
1788
2454
|
|
|
2455
|
+
/**
|
|
2456
|
+
* Clear an earlier failed recovery's verdict once this settlement has decided. Treated as CORRECTNESS, not
|
|
2457
|
+
* cleanup: main would report the component settled and load it while every worker read the stale marker and
|
|
2458
|
+
* failed it closed, so a failure here throws and lets main reach the same verdict. The journal outlives it
|
|
2459
|
+
* either way, so the next start settles again.
|
|
2460
|
+
*/
|
|
2461
|
+
async function clearUnsettledVerdict(deploymentDirPath: string, componentName: string): Promise<void> {
|
|
2462
|
+
try {
|
|
2463
|
+
await rm(join(deploymentDirPath, UNSETTLED_MARKER), { force: true });
|
|
2464
|
+
// Flushed here, not with whatever follows: the journal's removal must never be the one that survives
|
|
2465
|
+
// a crash alone, or the verdict outlives the only thing that would settle it again.
|
|
2466
|
+
await syncDirectory(deploymentDirPath);
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
throw new Error(
|
|
2469
|
+
`Could not clear the stale unsettled marker of ${componentName} at ` +
|
|
2470
|
+
`${join(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on every thread ` +
|
|
2471
|
+
`until that file can be removed: ${errorMessage(error)}`,
|
|
2472
|
+
{ cause: error }
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
1789
2477
|
/**
|
|
1790
2478
|
* One interrupted activation, under the component preparation lock. Ambiguity exists only while the live
|
|
1791
2479
|
* path is absent, and there the `complete` marker is the roll-forward authority: without it the candidate
|
|
@@ -1816,7 +2504,7 @@ async function settleInterruptedActivation(
|
|
|
1816
2504
|
const asideRecords = await inProgressAsideRecords(asideStagingDir);
|
|
1817
2505
|
|
|
1818
2506
|
const rollForward = async () => {
|
|
1819
|
-
if (!liveExists) await
|
|
2507
|
+
if (!liveExists) await renameThroughTransientHolder(candidateDirPath, liveDirPath);
|
|
1820
2508
|
// Unconditional, not only when THIS pass performed the rename: a crash after normal activation
|
|
1821
2509
|
// renamed the candidate but before it repaired the links leaves live present with stale targets, and
|
|
1822
2510
|
// gating the repair on the rename would skip exactly that case. Idempotent when there is nothing to
|
|
@@ -1831,7 +2519,7 @@ async function settleInterruptedActivation(
|
|
|
1831
2519
|
};
|
|
1832
2520
|
const rollBack = async (restoreFrom?: string) => {
|
|
1833
2521
|
if (restoreFrom) {
|
|
1834
|
-
await
|
|
2522
|
+
await renameThroughTransientHolder(restoreFrom, liveDirPath);
|
|
1835
2523
|
await syncRenameParents(restoreFrom, liveDirPath);
|
|
1836
2524
|
}
|
|
1837
2525
|
for (const record of asideRecords) {
|
|
@@ -1886,6 +2574,38 @@ async function settleInterruptedActivation(
|
|
|
1886
2574
|
`whichever of the two is not the release you want once you have determined which that is.`
|
|
1887
2575
|
);
|
|
1888
2576
|
}
|
|
2577
|
+
// A STAGED artifact is not a disposable build. `rollBack()` removes the whole deployment directory,
|
|
2578
|
+
// which is right for an immediate deploy — the candidate came from a payload the operator still has —
|
|
2579
|
+
// but wrong for one somebody staged deliberately and may have had its payload reclaimed. The
|
|
2580
|
+
// descriptor is what tells the two apart, and it is on disk precisely so recovery can. Returning the
|
|
2581
|
+
// artifact to dormant by removing only the journal leaves it exactly as `deployment_id` expects it.
|
|
2582
|
+
if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE))) {
|
|
2583
|
+
// This branch returns early and so reaches none of the settled tail below — which can be careless
|
|
2584
|
+
// about both, because it removes the whole directory afterwards. This one keeps it, so the order
|
|
2585
|
+
// the two unlinks REACH STORAGE decides whether the artifact survives: `.unsettled` with no
|
|
2586
|
+
// journal is a verdict nothing will ever settle and the next retention pass deletes the build on.
|
|
2587
|
+
// Its flush is the barrier between the two unlinks: skipping it would let the journal's removal
|
|
2588
|
+
// persist alone, leaving a verdict nothing will settle again. Throwing is safe here and leaves the
|
|
2589
|
+
// journal, so the next start settles again. Windows cannot fsync a directory, which is why the
|
|
2590
|
+
// residue pass also refuses to read this state as disposable — see DESIGN.md.
|
|
2591
|
+
await clearUnsettledVerdict(deploymentDirPath, journal.component);
|
|
2592
|
+
await rm(journalPath, { force: true });
|
|
2593
|
+
// Nothing may throw past the journal removal, the rule the settled tail follows: the caller
|
|
2594
|
+
// records a failure by writing `.unsettled`, which is the state this branch exists to avoid. An
|
|
2595
|
+
// unflushed removal is the safe direction — a power loss resurrects a journal that settles again.
|
|
2596
|
+
await syncDirectory(deploymentDirPath).catch((error) =>
|
|
2597
|
+
logger.warn(
|
|
2598
|
+
`Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant but ` +
|
|
2599
|
+
`could not flush that to storage; a power loss could resurrect its activation journal:`,
|
|
2600
|
+
errorForLog(error)
|
|
2601
|
+
)
|
|
2602
|
+
);
|
|
2603
|
+
logger.info?.(
|
|
2604
|
+
`Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant after an ` +
|
|
2605
|
+
`activation that never moved its live tree aside`
|
|
2606
|
+
);
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
1889
2609
|
await rollBack();
|
|
1890
2610
|
} else {
|
|
1891
2611
|
// The candidate is already live; only the tail of the transaction was lost.
|
|
@@ -1913,20 +2633,7 @@ async function settleInterruptedActivation(
|
|
|
1913
2633
|
// An earlier failed recovery may have left an unsettled marker here. Cleared BEFORE the journal and
|
|
1914
2634
|
// treated as correctness: main would report this component settled and load it, while every worker read
|
|
1915
2635
|
// the stale marker and failed it closed.
|
|
1916
|
-
|
|
1917
|
-
await rm(join(deploymentDirPath, UNSETTLED_MARKER), { force: true });
|
|
1918
|
-
} catch (error) {
|
|
1919
|
-
// The tree decision is applied, but the marker still says otherwise and every worker reads it and
|
|
1920
|
-
// fails the component closed. Thrown rather than returned so MAIN reaches that same verdict instead
|
|
1921
|
-
// of reporting the component settled — a split where main serves what every worker refuses is worse
|
|
1922
|
-
// than both refusing. The journal survives, so the next start settles again.
|
|
1923
|
-
throw new Error(
|
|
1924
|
-
`Settled the interrupted activation of ${journal.component} but could not clear its unsettled ` +
|
|
1925
|
-
`marker at ${join(deploymentDirPath, UNSETTLED_MARKER)}; the component stays failed closed on ` +
|
|
1926
|
-
`every thread until that file can be removed: ${errorMessage(error)}`,
|
|
1927
|
-
{ cause: error }
|
|
1928
|
-
);
|
|
1929
|
-
}
|
|
2636
|
+
await clearUnsettledVerdict(deploymentDirPath, journal.component);
|
|
1930
2637
|
await rm(journalPath, { force: true }).catch((error) =>
|
|
1931
2638
|
logger.warn(`Settled ${journal.component} but could not remove its activation journal:`, errorForLog(error))
|
|
1932
2639
|
);
|
|
@@ -2054,17 +2761,42 @@ export async function markCandidateComplete(
|
|
|
2054
2761
|
}
|
|
2055
2762
|
}
|
|
2056
2763
|
|
|
2764
|
+
/**
|
|
2765
|
+
* Make the newly created ancestors of a deployment directory durable, child-first.
|
|
2766
|
+
*
|
|
2767
|
+
* Only a staged artifact needs this. A deploy's own candidate is transient — power loss just abandons a
|
|
2768
|
+
* build nobody was told about — but a stage is ACKNOWLEDGED, and `writeControlFileDurably` flushes only the
|
|
2769
|
+
* control file's immediate parent while `ensureSecureStagingDirectory` flushes none. Without this a stage
|
|
2770
|
+
* can report success before the `.deploy-staging/<id>` entry exists on storage, and the automatic payload
|
|
2771
|
+
* reclaim may already have dropped the tarball it could have been rebuilt from. Best-effort on Windows, like
|
|
2772
|
+
* every other directory sync here.
|
|
2773
|
+
*/
|
|
2774
|
+
async function syncArtifactAncestors(deploymentDirPath: string): Promise<void> {
|
|
2775
|
+
const stagingRoot = dirname(deploymentDirPath);
|
|
2776
|
+
for (const directory of [deploymentDirPath, stagingRoot, dirname(stagingRoot)]) {
|
|
2777
|
+
await syncDirectory(directory);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2057
2781
|
/**
|
|
2058
2782
|
* Make a built and validated candidate live, as one compensating transaction over two effects: the live tree
|
|
2059
|
-
* moves aside, then the candidate takes its place. Root config is NOT one of them —
|
|
2060
|
-
* before the build, unchanged, and making it transactional is tracked separately
|
|
2783
|
+
* moves aside, then the candidate takes its place. Root config is NOT one of them — for an immediate deploy
|
|
2784
|
+
* it is still published before the build, unchanged, and making it transactional is tracked separately
|
|
2785
|
+
* (#2315). A delayed activation hands its artifact's recorded entry in as `afterJournal`, which publishes it
|
|
2786
|
+
* from inside the window a crash rolls forward from — see that call site.
|
|
2061
2787
|
*
|
|
2062
|
-
* The
|
|
2063
|
-
*
|
|
2064
|
-
*
|
|
2065
|
-
*
|
|
2788
|
+
* The candidate must ALREADY be certified: `markCandidateComplete` is the caller's, so a delayed activation
|
|
2789
|
+
* does not re-walk and re-fsync a whole dependency tree it certified when it was built. The activation
|
|
2790
|
+
* journal is still written and fsynced BEFORE the first rename, so a crash anywhere below is recoverable —
|
|
2791
|
+
* see `settleInterruptedActivation` for the state matrix. The second rename is the COMMIT POINT: nothing
|
|
2792
|
+
* after it may compensate, because the live path holds the candidate and renaming the aside back over it
|
|
2793
|
+
* cannot succeed.
|
|
2066
2794
|
*/
|
|
2067
|
-
export async function activateCandidateApplication(
|
|
2795
|
+
export async function activateCandidateApplication(
|
|
2796
|
+
application: Application,
|
|
2797
|
+
deploymentId: string,
|
|
2798
|
+
options: { afterJournal?: () => Promise<(() => Promise<void>) | void> } = {}
|
|
2799
|
+
): Promise<void> {
|
|
2068
2800
|
const liveDirPath = application.dirPath;
|
|
2069
2801
|
const candidateDirPath = candidateApplicationPath(liveDirPath, deploymentId);
|
|
2070
2802
|
const deploymentDirPath = candidateDeploymentDirPath(liveDirPath, deploymentId);
|
|
@@ -2080,69 +2812,187 @@ export async function activateCandidateApplication(application: Application, dep
|
|
|
2080
2812
|
throw new Error(`Cannot activate ${application.name}: no candidate build at ${candidateDirPath}`);
|
|
2081
2813
|
}
|
|
2082
2814
|
|
|
2083
|
-
await markCandidateComplete(liveDirPath, deploymentId, application.name);
|
|
2084
2815
|
const journalPath = activationJournalPath(liveDirPath, deploymentId);
|
|
2085
|
-
try {
|
|
2086
|
-
await writeControlFileDurably(
|
|
2087
|
-
journalPath,
|
|
2088
|
-
JSON.stringify({
|
|
2089
|
-
v: ACTIVATION_JOURNAL_VERSION,
|
|
2090
|
-
component: application.name,
|
|
2091
|
-
candidateId: deploymentId,
|
|
2092
|
-
})
|
|
2093
|
-
);
|
|
2094
|
-
} catch (error) {
|
|
2095
|
-
// An existing journal is a retry of this same activation, not a conflict.
|
|
2096
|
-
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
await ensureExtractionStagingDirectory(asideStagingDir);
|
|
2100
|
-
const liveExists = await lstat(liveDirPath).then(
|
|
2101
|
-
() => true,
|
|
2102
|
-
(error) => {
|
|
2103
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
2104
|
-
throw error;
|
|
2105
|
-
}
|
|
2106
|
-
);
|
|
2107
|
-
application.isNewComponent = !liveExists;
|
|
2108
2816
|
|
|
2109
2817
|
// B1 — the live tree moves aside. It stays the rollback source until B4 retires it.
|
|
2110
2818
|
let asidePath: string | undefined;
|
|
2111
2819
|
let priorAbsentRecordPath: string | undefined;
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
} else {
|
|
2116
|
-
priorAbsentRecordPath = join(
|
|
2117
|
-
asideStagingDir,
|
|
2118
|
-
`${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}${PRIOR_ABSENT_RECORD_SUFFIX}`
|
|
2119
|
-
);
|
|
2120
|
-
await writeFile(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
|
|
2121
|
-
}
|
|
2820
|
+
// The swap below moves the previous tree back and forth around every wait, so a chosen aside path no
|
|
2821
|
+
// longer implies the tree is at it — and compensation needs to know which.
|
|
2822
|
+
let liveIsDisplaced = false;
|
|
2122
2823
|
const restoreLive = async () => {
|
|
2123
|
-
if (
|
|
2124
|
-
|
|
2125
|
-
|
|
2824
|
+
if (liveIsDisplaced) {
|
|
2825
|
+
await renameThroughTransientHolder(asidePath!, liveDirPath);
|
|
2826
|
+
liveIsDisplaced = false;
|
|
2827
|
+
} else if (priorAbsentRecordPath) await rm(priorAbsentRecordPath, { force: true });
|
|
2828
|
+
// Nothing was displaced and no record was written, so there is nothing to put back and no rename to
|
|
2829
|
+
// flush — the failure happened before the first effect.
|
|
2830
|
+
if (asidePath || priorAbsentRecordPath) {
|
|
2831
|
+
await syncRenameParents(asidePath ?? priorAbsentRecordPath!, liveDirPath);
|
|
2832
|
+
}
|
|
2126
2833
|
};
|
|
2127
2834
|
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2835
|
+
/**
|
|
2836
|
+
* Put a compensated candidate back to DORMANT — complete, described, no journal — so it is a retryable
|
|
2837
|
+
* artifact rather than one the next preparation destroys.
|
|
2838
|
+
*
|
|
2839
|
+
* Without this, a failure that leaves the journal in place makes `settleStagingForComponent` read
|
|
2840
|
+
* live-plus-candidate as an activation that never got there and remove the whole deployment directory;
|
|
2841
|
+
* for a first deploy the restored state is live-ABSENT, and it rolls the candidate forward instead,
|
|
2842
|
+
* ahead of the caller's own verification. Both destroy an artifact whose whole purpose is to be
|
|
2843
|
+
* activated again.
|
|
2844
|
+
*
|
|
2845
|
+
* The unlink needs its own barrier: syncing the aside directories persists the rollback record's
|
|
2846
|
+
* disposal, not the journal's, so without this sync a power loss resurrects a journal the operator was
|
|
2847
|
+
* told had been rolled back. Best-effort by necessity — failing here must not replace the activation
|
|
2848
|
+
* failure the caller is reporting — so a failure says explicitly that the artifact is not retryable, and
|
|
2849
|
+
* the surviving journal is exactly what startup recovery settles.
|
|
2850
|
+
*/
|
|
2851
|
+
const returnToDormant = async () => {
|
|
2852
|
+
try {
|
|
2853
|
+
await rm(journalPath, { force: true });
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
application.logger.warn(
|
|
2856
|
+
`Restored ${application.name} after a failed activation, but its staged build ${deploymentId} still ` +
|
|
2857
|
+
`carries an activation journal and is not retryable until recovery settles it:`,
|
|
2858
|
+
error
|
|
2859
|
+
);
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
// The journal is gone in this process, so the artifact is retryable now; what is uncertain is whether
|
|
2863
|
+
// its removal reached storage.
|
|
2864
|
+
await syncDirectory(deploymentDirPath).catch((error) =>
|
|
2865
|
+
application.logger.warn(
|
|
2866
|
+
`Restored ${application.name} and returned its staged build ${deploymentId} to a retryable state, but ` +
|
|
2867
|
+
`could not flush that to storage; a power loss could resurrect its activation journal:`,
|
|
2868
|
+
error
|
|
2869
|
+
)
|
|
2870
|
+
);
|
|
2871
|
+
};
|
|
2872
|
+
|
|
2873
|
+
/**
|
|
2874
|
+
* One pre-commit failure boundary, with the journal write inside it: every step from there to the commit
|
|
2875
|
+
* rename leaves a journal behind if it only rethrows, and the next settlement then deletes a certified
|
|
2876
|
+
* artifact, or (first deploy, live absent) activates it with nobody asking. Nothing below the commit
|
|
2877
|
+
* rename may enter this catch — see B2.
|
|
2878
|
+
*/
|
|
2879
|
+
let pendingEffect = 'record the activation';
|
|
2880
|
+
let undoAfterJournal: (() => Promise<void>) | void;
|
|
2132
2881
|
try {
|
|
2882
|
+
try {
|
|
2883
|
+
await writeControlFileDurably(
|
|
2884
|
+
journalPath,
|
|
2885
|
+
JSON.stringify({
|
|
2886
|
+
v: ACTIVATION_JOURNAL_VERSION,
|
|
2887
|
+
component: application.name,
|
|
2888
|
+
candidateId: deploymentId,
|
|
2889
|
+
})
|
|
2890
|
+
);
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
// An existing journal is a retry of this same activation, not a conflict.
|
|
2893
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
pendingEffect = 'prepare the component staging directory';
|
|
2897
|
+
await ensureExtractionStagingDirectory(asideStagingDir);
|
|
2898
|
+
pendingEffect = 'read the live component directory';
|
|
2899
|
+
const liveExists = await lstat(liveDirPath).then(
|
|
2900
|
+
() => true,
|
|
2901
|
+
(error) => {
|
|
2902
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
2903
|
+
throw error;
|
|
2904
|
+
}
|
|
2905
|
+
);
|
|
2906
|
+
application.isNewComponent = !liveExists;
|
|
2907
|
+
|
|
2908
|
+
pendingEffect = 'move the previous version aside';
|
|
2909
|
+
if (liveExists) {
|
|
2910
|
+
asidePath = join(asideStagingDir, `${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}`);
|
|
2911
|
+
await renameThroughTransientHolder(liveDirPath, asidePath);
|
|
2912
|
+
liveIsDisplaced = true;
|
|
2913
|
+
} else {
|
|
2914
|
+
priorAbsentRecordPath = join(
|
|
2915
|
+
asideStagingDir,
|
|
2916
|
+
`${IN_PROGRESS_ASIDE_PREFIX}${Date.now()}-${process.pid}-${randomUUID()}${PRIOR_ABSENT_RECORD_SUFFIX}`
|
|
2917
|
+
);
|
|
2918
|
+
await writeFile(priorAbsentRecordPath, '', { flag: 'wx', mode: 0o600 });
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Letting a storage failure escape here leaves live already moved aside, and the caller reads an
|
|
2922
|
+
// uncompensated throw as an ordinary build failure and discards the candidate, its `.complete` marker
|
|
2923
|
+
// and its journal: the component ends up with no version at all and nothing saying how to get one back.
|
|
2924
|
+
pendingEffect = 'record the displaced component directory';
|
|
2133
2925
|
await syncRenameParents(liveDirPath, asidePath ?? priorAbsentRecordPath!);
|
|
2134
|
-
} catch (error) {
|
|
2135
|
-
await compensate(error, 'record the displaced component directory', restoreLive, application);
|
|
2136
|
-
throw error;
|
|
2137
|
-
}
|
|
2138
2926
|
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2927
|
+
// Config is published HERE — after B1, before the commit — because this is the only point where the
|
|
2928
|
+
// on-disk state recovery would find rolls FORWARD to the certified artifact: live is displaced, the
|
|
2929
|
+
// candidate is complete, and the rollback record exists. Publishing before B1 (with or without the
|
|
2930
|
+
// journal) leaves live present and the candidate present with no rollback record, which settlement
|
|
2931
|
+
// reads as an activation that never started: it deletes the deployment directory, and the next boot
|
|
2932
|
+
// re-resolves the published package identifier from the registry instead — the substitution this step
|
|
2933
|
+
// exists to prevent.
|
|
2934
|
+
//
|
|
2935
|
+
// A crash in the remaining window — after the roll-forward state exists but before this publish — is
|
|
2936
|
+
// the inverse hazard: `rollForward()` renames the candidate live and publishes nothing, so the
|
|
2937
|
+
// certified artifact serves under the PREVIOUS release's config. That includes its ISOLATION intent,
|
|
2938
|
+
// which is a containment boundary and not just a version string: a component staged to run isolated
|
|
2939
|
+
// comes back non-isolated after an ordinary crash, with nothing in the operation reporting it.
|
|
2940
|
+
// Closing it needs config to be an effect of the journal itself, which is #2315 step 3.
|
|
2941
|
+
pendingEffect = 'publish the root configuration';
|
|
2942
|
+
undoAfterJournal = await options.afterJournal?.();
|
|
2943
|
+
|
|
2944
|
+
// B2 — the candidate becomes live. THE RENAME IS THE COMMIT POINT: nothing after it may compensate,
|
|
2945
|
+
// because the live path now holds the candidate and renaming the aside back over it cannot succeed. A
|
|
2946
|
+
// compensating step there fails its own rollback and reports a failure for a deploy that is live. It is
|
|
2947
|
+
// the LAST statement in this block for that reason.
|
|
2948
|
+
pendingEffect = 'move the candidate into place';
|
|
2949
|
+
await renameThroughTransientHolder(candidateDirPath, liveDirPath, {
|
|
2950
|
+
// The previous version occupies the live path through the wait rather than the component being
|
|
2951
|
+
// absent for the whole budget: a read of a component file, and any concurrent scan of the
|
|
2952
|
+
// components root, still finds the last committed tree. (Watchers are already paused for the
|
|
2953
|
+
// deploy, so they are not what this protects.) A first-ever deploy has nothing to put back.
|
|
2954
|
+
onBackoff: async (delayMs, deadline) => {
|
|
2955
|
+
if (!liveIsDisplaced) return delay(delayMs);
|
|
2956
|
+
await renameThroughTransientHolder(asidePath!, liveDirPath, { deadline });
|
|
2957
|
+
liveIsDisplaced = false;
|
|
2958
|
+
await syncRenameParents(asidePath!, liveDirPath);
|
|
2959
|
+
await delay(delayMs);
|
|
2960
|
+
await renameThroughTransientHolder(liveDirPath, asidePath!, { deadline });
|
|
2961
|
+
liveIsDisplaced = true;
|
|
2962
|
+
await syncRenameParents(liveDirPath, asidePath!);
|
|
2963
|
+
},
|
|
2964
|
+
});
|
|
2144
2965
|
} catch (error) {
|
|
2145
|
-
|
|
2966
|
+
// Whether the journal can still carry this activation forward, decided BEFORE compensation removes the
|
|
2967
|
+
// evidence it is read from. Only a first-ever deploy qualifies: `restoreLive` leaves the live path
|
|
2968
|
+
// absent, and recovery reads absent-plus-complete-candidate as a roll forward. A component that
|
|
2969
|
+
// already had a tree gets that tree back and loses its rollback record with it, so the next settle
|
|
2970
|
+
// reads live-plus-candidate-with-no-record and returns the artifact to dormant whatever the journal
|
|
2971
|
+
// says — keeping it there defers the same verdict to the next start and strands config until then.
|
|
2972
|
+
const recoveryCanRollForward = priorAbsentRecordPath !== undefined;
|
|
2973
|
+
await compensate(error, pendingEffect, restoreLive, application);
|
|
2974
|
+
let configRestored = true;
|
|
2975
|
+
if (undoAfterJournal) {
|
|
2976
|
+
configRestored = await undoAfterJournal().then(
|
|
2977
|
+
() => true,
|
|
2978
|
+
(undoError) => {
|
|
2979
|
+
application.logger.warn(
|
|
2980
|
+
`Restored ${application.name} after a failed activation but could not restore its root config, ` +
|
|
2981
|
+
`which still names deployment ${deploymentId}` +
|
|
2982
|
+
(recoveryCanRollForward
|
|
2983
|
+
? '; keeping its activation journal so recovery can roll the certified build forward instead:'
|
|
2984
|
+
: '. The certified build stays dormant and the previous release stays live, so the two ' +
|
|
2985
|
+
'disagree until an operator republishes the component or activates it again:'),
|
|
2986
|
+
undoError
|
|
2987
|
+
);
|
|
2988
|
+
return false;
|
|
2989
|
+
}
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2992
|
+
// Kept only where it changes the outcome. Config is stranded either way for a component that already
|
|
2993
|
+
// had a tree — the durable-config window #2315 step 3 closes — and a journal that recovery will only
|
|
2994
|
+
// settle back to dormant buys nothing for holding it.
|
|
2995
|
+
if (configRestored || !recoveryCanRollForward) await returnToDormant();
|
|
2146
2996
|
throw error;
|
|
2147
2997
|
}
|
|
2148
2998
|
|
|
@@ -2344,15 +3194,26 @@ async function discardCandidate(application: Application, deploymentId: string):
|
|
|
2344
3194
|
* Failure needs no compensation, which is the whole point: nothing about the live component was modified,
|
|
2345
3195
|
* so the abandoned candidate is simply removed and the error propagates.
|
|
2346
3196
|
*/
|
|
2347
|
-
export async function buildCandidateApplication(
|
|
3197
|
+
export async function buildCandidateApplication(
|
|
3198
|
+
application: Application,
|
|
3199
|
+
deploymentId: string,
|
|
3200
|
+
options: { rejectLinkSource?: boolean } = {}
|
|
3201
|
+
): Promise<string> {
|
|
2348
3202
|
const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
|
|
2349
3203
|
const candidateDirPath = candidateApplicationPath(application.dirPath, deploymentId);
|
|
2350
3204
|
await ensureSecureStagingDirectory(dirname(deploymentDirPath));
|
|
2351
|
-
await
|
|
3205
|
+
await claimDeploymentDirectory(deploymentDirPath, application.name);
|
|
2352
3206
|
try {
|
|
2353
3207
|
// Replaced, not extracted into: a prior attempt on this id may have left a partial tree.
|
|
2354
3208
|
await rm(candidateDirPath, { recursive: true, force: true });
|
|
2355
3209
|
const resolved = await resolveApplicationTarball(application);
|
|
3210
|
+
if (resolved.kind === 'link' && options.rejectLinkSource) {
|
|
3211
|
+
// What the operator asked for, not a server fault: the same component deploys immediately.
|
|
3212
|
+
throw new ClientError(
|
|
3213
|
+
`Cannot stage ${application.name} from ${application.packageIdentifier}: a 'file:' directory is linked ` +
|
|
3214
|
+
`rather than copied, so the bytes activated later are not the bytes this build certified`
|
|
3215
|
+
);
|
|
3216
|
+
}
|
|
2356
3217
|
if (resolved.kind === 'link') {
|
|
2357
3218
|
// A `file:` directory becomes a symlink AT THE CANDIDATE PATH, so it is validated and swapped in
|
|
2358
3219
|
// like any other candidate instead of appearing at the live path unvalidated.
|
|
@@ -2593,6 +3454,15 @@ export async function dropComponentDirectory(
|
|
|
2593
3454
|
asideStagingDir,
|
|
2594
3455
|
new Set([droppedPath])
|
|
2595
3456
|
);
|
|
3457
|
+
// A dropped component has no next deploy to bound its dormant builds.
|
|
3458
|
+
try {
|
|
3459
|
+
await pruneDormantBuilds(componentName, await dormantBuildsOf(dirname(componentDirPath), componentName), 0);
|
|
3460
|
+
} catch (error) {
|
|
3461
|
+
componentLogger.warn(
|
|
3462
|
+
`Dropped ${componentName} but could not reclaim its dormant staged builds:`,
|
|
3463
|
+
errorForLog(error)
|
|
3464
|
+
);
|
|
3465
|
+
}
|
|
2596
3466
|
}
|
|
2597
3467
|
|
|
2598
3468
|
async function cleanupExtractionPaths(
|
|
@@ -2986,8 +3856,15 @@ export async function installApplication(application: Application, buildDirPath
|
|
|
2986
3856
|
// If node_modules doesn't exist, we need to install dependencies
|
|
2987
3857
|
}
|
|
2988
3858
|
|
|
3859
|
+
const allowInstallScripts = !!application.install?.allowInstallScripts;
|
|
3860
|
+
|
|
2989
3861
|
// If custom install command is specified, run it
|
|
2990
3862
|
if (application.install?.command) {
|
|
3863
|
+
if (application.install.allowInstallScripts === undefined) {
|
|
3864
|
+
application.logger.warn(
|
|
3865
|
+
`Application ${application.name} uses install_command without install_allow_scripts; package lifecycle scripts are disabled by default for npm and tools that honor npm_config_ignore_scripts, including npm run pre/post hooks. Set install_allow_scripts (or install.allowInstallScripts in root config) to true to opt in`
|
|
3866
|
+
);
|
|
3867
|
+
}
|
|
2991
3868
|
const [command, ...args] = application.install.command.split(' ');
|
|
2992
3869
|
const customOnLine = application.onInstallLine
|
|
2993
3870
|
? (stream: 'stdout' | 'stderr', line: string) => application.onInstallLine!(command, stream, line)
|
|
@@ -2999,7 +3876,9 @@ export async function installApplication(application: Application, buildDirPath
|
|
|
2999
3876
|
buildDirPath,
|
|
3000
3877
|
application.install?.timeout,
|
|
3001
3878
|
customOnLine,
|
|
3002
|
-
application.npmUserconfigPath
|
|
3879
|
+
application.npmUserconfigPath,
|
|
3880
|
+
undefined,
|
|
3881
|
+
!allowInstallScripts
|
|
3003
3882
|
);
|
|
3004
3883
|
// if it succeeds, return
|
|
3005
3884
|
if (code === 0) {
|
|
@@ -3019,7 +3898,6 @@ export async function installApplication(application: Application, buildDirPath
|
|
|
3019
3898
|
);
|
|
3020
3899
|
}
|
|
3021
3900
|
|
|
3022
|
-
const allowInstallScripts = !!application.install?.allowInstallScripts;
|
|
3023
3901
|
const { packageManager } = packageJSON.devEngines || {};
|
|
3024
3902
|
if (dependencyFieldHasWork(packageJSON, 'devDependencies')) {
|
|
3025
3903
|
application.logger.warn(
|
|
@@ -3359,6 +4237,7 @@ export function shouldPackLocalDirectory(packageIdentifier: string | undefined,
|
|
|
3359
4237
|
* @returns A promise that resolves when all preparation steps complete.
|
|
3360
4238
|
*/
|
|
3361
4239
|
export type PrepareApplicationOptions = {
|
|
4240
|
+
beforePrepare?: () => Promise<void>;
|
|
3362
4241
|
/**
|
|
3363
4242
|
* Runs against the built candidate while the live version is still serving, and BEFORE the swap. A
|
|
3364
4243
|
* throw here means the candidate never goes live — which is the whole difference from the previous
|
|
@@ -3366,15 +4245,46 @@ export type PrepareApplicationOptions = {
|
|
|
3366
4245
|
* broken release.
|
|
3367
4246
|
*/
|
|
3368
4247
|
validateCandidate?: (candidateDirPath: string) => Promise<void>;
|
|
4248
|
+
/**
|
|
4249
|
+
* Names `.deploy-staging/<artifactId>`, and is not the deploy-lifecycle token: that token de-duplicates
|
|
4250
|
+
* overlapping deploys of one component and is released by the first matching end, so two activations of
|
|
4251
|
+
* one artifact sharing it would resume watchers while the second is still swapping. Defaults to the
|
|
4252
|
+
* lifecycle token, which is unique per invocation.
|
|
4253
|
+
*/
|
|
4254
|
+
artifactId?: string;
|
|
4255
|
+
/**
|
|
4256
|
+
* `deploy` builds, certifies and activates. `stage` builds and certifies, then stops, leaving an
|
|
4257
|
+
* artifact a later `activate` can address by `artifactId`. `activate` verifies an existing artifact
|
|
4258
|
+
* and swaps it in, doing no resolution, no install and no network work at all.
|
|
4259
|
+
*/
|
|
4260
|
+
mode?: 'deploy' | 'stage' | 'activate';
|
|
4261
|
+
/**
|
|
4262
|
+
* `stage` only: the build's declared intent, recorded with the artifact for whoever activates it.
|
|
4263
|
+
* A callback rather than a value because `beforePrepare` is what determines it, and that runs after
|
|
4264
|
+
* these options have been constructed.
|
|
4265
|
+
*/
|
|
4266
|
+
describeArtifact?: () => { rootConfig: Record<string, unknown> | null; isolated: boolean };
|
|
4267
|
+
/**
|
|
4268
|
+
* `activate` only: publish the artifact's recorded root-config entry, immediately before the swap. May
|
|
4269
|
+
* return an undo, run if the activation then fails before it commits — otherwise a failed activation
|
|
4270
|
+
* leaves config naming a release that is not live, which the next boot install would resolve and build
|
|
4271
|
+
* from scratch over a component whose certified artifact is sitting beside it.
|
|
4272
|
+
*/
|
|
4273
|
+
publishRootConfig?: (entry: Record<string, unknown>) => Promise<(() => Promise<void>) | void>;
|
|
4274
|
+
/** `activate` only: admit the artifact's isolation intent under the preparation lock, before the swap. */
|
|
4275
|
+
admitIsolation?: (descriptor: ArtifactDescriptor) => Promise<void>;
|
|
3369
4276
|
};
|
|
3370
4277
|
|
|
3371
4278
|
export async function prepareApplication(application: Application, options: PrepareApplicationOptions = {}) {
|
|
3372
|
-
const
|
|
4279
|
+
const lifecycleToken = await broadcastDeployStart(application.name);
|
|
4280
|
+
const mode = options.mode ?? 'deploy';
|
|
4281
|
+
const artifactId = options.artifactId ?? lifecycleToken;
|
|
3373
4282
|
try {
|
|
3374
4283
|
const commandTimeoutMs = application.install?.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
3375
4284
|
await withComponentPreparationLock(
|
|
3376
4285
|
application.dirPath,
|
|
3377
4286
|
async () => {
|
|
4287
|
+
await options.beforePrepare?.();
|
|
3378
4288
|
const asideStagingDir = extractionStagingDirectory(application.dirPath);
|
|
3379
4289
|
let recoveryPending = true;
|
|
3380
4290
|
try {
|
|
@@ -3385,7 +4295,12 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3385
4295
|
}
|
|
3386
4296
|
// BEFORE the legacy pass. That pass refuses to restore while a journal survives, so skipping
|
|
3387
4297
|
// this would not lose data — it would just stall the deploy behind its own unsettled state.
|
|
3388
|
-
|
|
4298
|
+
//
|
|
4299
|
+
// The id this request names is pinned against the retention this runs. An activation would
|
|
4300
|
+
// otherwise have its own artifact deleted by its own preamble; and a redelivered stage would
|
|
4301
|
+
// have the artifact evicted out from under the exclusive claim below, which would then rebuild
|
|
4302
|
+
// different bytes under an id that already named some.
|
|
4303
|
+
await settleStagingForComponent(dirname(application.dirPath), application.name, artifactId);
|
|
3389
4304
|
if (recoveryPending) {
|
|
3390
4305
|
await ensureExtractionStagingDirectory(asideStagingDir);
|
|
3391
4306
|
await recoverOrCleanupStaleExtractionPaths(application, asideStagingDir);
|
|
@@ -3400,6 +4315,10 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3400
4315
|
throw error;
|
|
3401
4316
|
}
|
|
3402
4317
|
));
|
|
4318
|
+
if (mode === 'activate') {
|
|
4319
|
+
await activateStagedArtifact(application, artifactId, previousPackageMetadata, options);
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
3403
4322
|
try {
|
|
3404
4323
|
// Materialize the per-deploy `.npmrc` before the build so both `npm pack` and `npm install`
|
|
3405
4324
|
// authenticate against the private registry; always remove it afterward.
|
|
@@ -3410,7 +4329,9 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3410
4329
|
// credential is already gone before any install script runs. This finally covers the paths
|
|
3411
4330
|
// that fail before it gets there.
|
|
3412
4331
|
await application.startGitCredentialSession();
|
|
3413
|
-
candidateDirPath = await buildCandidateApplication(application,
|
|
4332
|
+
candidateDirPath = await buildCandidateApplication(application, artifactId, {
|
|
4333
|
+
rejectLinkSource: mode === 'stage',
|
|
4334
|
+
});
|
|
3414
4335
|
} finally {
|
|
3415
4336
|
await application.cleanupGitCredentialSession();
|
|
3416
4337
|
}
|
|
@@ -3425,7 +4346,25 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3425
4346
|
application.installationIsOpaque
|
|
3426
4347
|
);
|
|
3427
4348
|
}
|
|
3428
|
-
|
|
4349
|
+
if (mode === 'stage') {
|
|
4350
|
+
await assertOwnedArtifactTree(candidateDirPath, application.name);
|
|
4351
|
+
// The descriptor goes first so `.complete` vouches for it: after this pair the artifact
|
|
4352
|
+
// is dormant, and a delayed activation reads its build's decisions from here because
|
|
4353
|
+
// nothing on disk carries them otherwise.
|
|
4354
|
+
const declared = options.describeArtifact?.() ?? { rootConfig: null, isolated: false };
|
|
4355
|
+
await writeArtifactDescriptor(application.dirPath, artifactId, {
|
|
4356
|
+
v: ARTIFACT_DESCRIPTOR_VERSION,
|
|
4357
|
+
component: application.name,
|
|
4358
|
+
rootConfig: declared.rootConfig,
|
|
4359
|
+
installationIsOpaque: application.installationIsOpaque,
|
|
4360
|
+
isolated: declared.isolated,
|
|
4361
|
+
});
|
|
4362
|
+
await markCandidateComplete(application.dirPath, artifactId, application.name);
|
|
4363
|
+
await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
await markCandidateComplete(application.dirPath, artifactId, application.name);
|
|
4367
|
+
await activateCandidateApplication(application, artifactId);
|
|
3429
4368
|
} catch (error) {
|
|
3430
4369
|
// The builder's own cleanup only covers a failed BUILD. A rejected validation, or an
|
|
3431
4370
|
// activation that was cleanly compensated, would otherwise leave a whole installed
|
|
@@ -3435,7 +4374,7 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3435
4374
|
// path may be absent, and the candidate plus its `.complete` marker and journal are exactly
|
|
3436
4375
|
// what recovery needs to roll the validated deploy forward at the next start. Discarding
|
|
3437
4376
|
// them there trades a bounded disk cost for a component with no version at all.
|
|
3438
|
-
if (!compensationIncomplete(error)) await discardCandidate(application,
|
|
4377
|
+
if (!compensationIncomplete(error)) await discardCandidate(application, artifactId);
|
|
3439
4378
|
throw error;
|
|
3440
4379
|
}
|
|
3441
4380
|
} finally {
|
|
@@ -3464,8 +4403,82 @@ export async function prepareApplication(application: Application, options: Prep
|
|
|
3464
4403
|
}
|
|
3465
4404
|
);
|
|
3466
4405
|
} finally {
|
|
3467
|
-
broadcastDeployEnd(application.name,
|
|
4406
|
+
broadcastDeployEnd(application.name, lifecycleToken);
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
/**
|
|
4411
|
+
* Swap an already-certified artifact into the live path. Called with the component's preparation lock held
|
|
4412
|
+
* and after the same recovery preamble every build runs, so the state read here is settled.
|
|
4413
|
+
*
|
|
4414
|
+
* Nothing is resolved, fetched or installed: the bytes were certified when they were staged, which is the
|
|
4415
|
+
* whole point of addressing one by id. Verification is therefore the only gate, and it is strict — this is
|
|
4416
|
+
* activation input for a build this process did not make and may not have made on this node.
|
|
4417
|
+
*
|
|
4418
|
+
* A rejection here must NOT discard: the artifact belongs to whoever staged it, a wrong-component request
|
|
4419
|
+
* must not delete another component's build, and a retry needs what a failed attempt left behind.
|
|
4420
|
+
*/
|
|
4421
|
+
async function activateStagedArtifact(
|
|
4422
|
+
application: Application,
|
|
4423
|
+
artifactId: string,
|
|
4424
|
+
previousPackageMetadata: InstalledPackageMetadata,
|
|
4425
|
+
options: PrepareApplicationOptions
|
|
4426
|
+
): Promise<void> {
|
|
4427
|
+
const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, artifactId);
|
|
4428
|
+
const candidateDirPath = candidateApplicationPath(application.dirPath, artifactId);
|
|
4429
|
+
// 404 means only one thing — no artifact answers to this id here — so a caller can tell "never existed or
|
|
4430
|
+
// already used" from "present, but not something this can activate", which is every other refusal below
|
|
4431
|
+
// and a 409 like the rest of that family.
|
|
4432
|
+
const refuse = (why: string, statusCode: number) =>
|
|
4433
|
+
new ClientError(`Cannot deploy ${application.name} from deployment ${artifactId}: ${why}`, statusCode);
|
|
4434
|
+
const missing = (why: string) => refuse(why, 404);
|
|
4435
|
+
const unusable = (why: string) => refuse(why, 409);
|
|
4436
|
+
|
|
4437
|
+
const owner = await candidateComponentName(deploymentDirPath);
|
|
4438
|
+
if (owner === undefined) throw missing('there is no staged build with that id on this node');
|
|
4439
|
+
if (owner !== application.name) throw unusable(`that staged build belongs to '${owner}'`);
|
|
4440
|
+
if (!(await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER)))) {
|
|
4441
|
+
throw unusable('its build never completed');
|
|
4442
|
+
}
|
|
4443
|
+
if (await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) {
|
|
4444
|
+
throw unusable('recovery could not settle it, so it is not safe to activate');
|
|
4445
|
+
}
|
|
4446
|
+
if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) {
|
|
4447
|
+
throw unusable('an activation of it is unsettled');
|
|
4448
|
+
}
|
|
4449
|
+
const candidateStat = await presentOrAbsent(candidateDirPath);
|
|
4450
|
+
if (!candidateStat || !candidateStat.isDirectory()) {
|
|
4451
|
+
// Unlike an immediate deploy, a symlink is refused: a `file:` directory is linked rather than
|
|
4452
|
+
// copied, so what it points at now is not what was certified. Staging rejects the source for the
|
|
4453
|
+
// same reason; this is the other end of the same rule, for an artifact staged by an older build.
|
|
4454
|
+
throw unusable('its build tree is missing or is a link rather than a copy');
|
|
4455
|
+
}
|
|
4456
|
+
const descriptor = await readArtifactDescriptor(deploymentDirPath, application.name);
|
|
4457
|
+
if (!descriptor) throw unusable('it does not record what its build decided');
|
|
4458
|
+
|
|
4459
|
+
await options.admitIsolation?.(descriptor);
|
|
4460
|
+
|
|
4461
|
+
// `.complete` is a durability marker over the bytes, not a seal on them, and every check that made this
|
|
4462
|
+
// artifact safe ran at stage time. The link rule especially cannot be skipped:
|
|
4463
|
+
// `repairRelocatedDependencyLinks` runs past the commit point and can only warn, so a link planted while
|
|
4464
|
+
// the artifact sat dormant would otherwise go live with the operation reporting success.
|
|
4465
|
+
await assertOwnedArtifactTree(candidateDirPath, application.name, 'activate');
|
|
4466
|
+
// Also the activation's `prepare` phase end: the deploy path emits `prepare`/`start` for every mode but
|
|
4467
|
+
// only ever emitted its `done` from here.
|
|
4468
|
+
await options.validateCandidate?.(candidateDirPath);
|
|
4469
|
+
|
|
4470
|
+
if (!application.isNewComponent) {
|
|
4471
|
+
application.packageMetadataChanged = installedRuntimeChanged(
|
|
4472
|
+
previousPackageMetadata,
|
|
4473
|
+
await readInstalledPackageMetadata(candidateDirPath),
|
|
4474
|
+
descriptor.installationIsOpaque
|
|
4475
|
+
);
|
|
3468
4476
|
}
|
|
4477
|
+
await activateCandidateApplication(application, artifactId, {
|
|
4478
|
+
// Returns its own undo, which the swap runs inside its pre-commit boundary — see there for why it
|
|
4479
|
+
// cannot be run out here.
|
|
4480
|
+
afterJournal: descriptor.rootConfig ? () => options.publishRootConfig!(descriptor.rootConfig!) : undefined,
|
|
4481
|
+
});
|
|
3469
4482
|
}
|
|
3470
4483
|
|
|
3471
4484
|
/**
|
|
@@ -3856,7 +4869,8 @@ export async function nonInteractiveSpawn(
|
|
|
3856
4869
|
timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
3857
4870
|
onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
|
|
3858
4871
|
npmUserconfigPath?: string,
|
|
3859
|
-
gitCredentialEnv?: Record<string, string
|
|
4872
|
+
gitCredentialEnv?: Record<string, string>,
|
|
4873
|
+
ignoreNpmScripts = false
|
|
3860
4874
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
3861
4875
|
const gitSSH = await materializeGitSSH();
|
|
3862
4876
|
try {
|
|
@@ -3869,7 +4883,8 @@ export async function nonInteractiveSpawn(
|
|
|
3869
4883
|
onLine,
|
|
3870
4884
|
npmUserconfigPath,
|
|
3871
4885
|
gitSSH?.command,
|
|
3872
|
-
gitCredentialEnv
|
|
4886
|
+
gitCredentialEnv,
|
|
4887
|
+
ignoreNpmScripts
|
|
3873
4888
|
);
|
|
3874
4889
|
} finally {
|
|
3875
4890
|
await gitSSH?.cleanup();
|
|
@@ -3885,7 +4900,8 @@ function spawnWithEnv(
|
|
|
3885
4900
|
onLine: ((stream: 'stdout' | 'stderr', line: string) => void) | undefined,
|
|
3886
4901
|
npmUserconfigPath: string | undefined,
|
|
3887
4902
|
gitSSHCommand: string | undefined,
|
|
3888
|
-
gitCredentialEnv: Record<string, string> | undefined
|
|
4903
|
+
gitCredentialEnv: Record<string, string> | undefined,
|
|
4904
|
+
ignoreNpmScripts: boolean
|
|
3889
4905
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
3890
4906
|
return new Promise((resolve, reject) => {
|
|
3891
4907
|
logger
|
|
@@ -3921,6 +4937,12 @@ function spawnWithEnv(
|
|
|
3921
4937
|
}
|
|
3922
4938
|
env.npm_config_userconfig = npmUserconfigPath;
|
|
3923
4939
|
}
|
|
4940
|
+
if (ignoreNpmScripts) {
|
|
4941
|
+
for (const key of Object.keys(env)) {
|
|
4942
|
+
if (key.toLowerCase() === 'npm_config_ignore_scripts') delete env[key];
|
|
4943
|
+
}
|
|
4944
|
+
env.npm_config_ignore_scripts = 'true';
|
|
4945
|
+
}
|
|
3924
4946
|
|
|
3925
4947
|
if (process.platform === 'win32' && command === 'npm') {
|
|
3926
4948
|
command = 'npm.cmd';
|