@nowcrew/daemon 0.6.77-beta.7 → 0.6.77-beta.9
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/README.md +27 -0
- package/dist/automatic-daemon-installer.js +15 -6
- package/dist/automatic-install-target.js +34 -9
- package/dist/computer-cli.js +39 -11
- package/dist/computer-profile.js +15 -2
- package/dist/daemon-installation-lease.js +7 -4
- package/dist/daemon-local-home.js +108 -0
- package/dist/daemon-migration-controller.js +15 -4
- package/dist/daemon-update-eligibility.js +15 -7
- package/dist/host-execution-coordinator.js +4 -3
- package/dist/main.js +13 -2
- package/dist/managed-service-diagnostics.js +1 -0
- package/dist/managed-service-registry.js +12 -7
- package/dist/managed-service-startup.js +4 -1
- package/dist/serve.js +5 -2
- package/dist/slog.js +14 -1
- package/dist/storage-control.js +12 -1
- package/dist/storage-identity.js +15 -1
- package/dist/storage-migration-controller.js +25 -4
- package/dist/storage-ownership.js +55 -0
- package/dist/storage-runtime-controller.js +29 -7
- package/dist/storage-runtime-readiness.js +26 -4
- package/dist/storage-runtime.js +18 -2
- package/dist/storage-status-delivery.js +4 -0
- package/dist/storage-status-replay.js +16 -1
- package/dist/storage-worker-executor.js +1 -0
- package/dist/storage-worker-operation.js +4 -0
- package/package.json +9 -10
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { defaultProcessController } from "./execution-journal.js";
|
|
6
6
|
import { createJournalLease, createJournalLeaseRegistry, JournalLockedError, } from "./execution-journal-lock.js";
|
|
7
7
|
import { dslog } from "./slog.js";
|
|
8
|
+
import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
|
|
8
9
|
const HOST_EXECUTION_SLOT_COUNT = 10;
|
|
9
10
|
const HOST_STARTUP_SLOT_COUNT = 10;
|
|
10
11
|
const HOST_STARTUP_GAP_MS = 500;
|
|
@@ -16,10 +17,10 @@ export class HostReservationCancelledError extends Error {
|
|
|
16
17
|
super(message);
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
|
-
export function defaultHostExecutionCoordinatorRoot(userHome = homedir()) {
|
|
20
|
+
export function defaultHostExecutionCoordinatorRoot(userHome = homedir(), localHome) {
|
|
20
21
|
// Deliberately independent of CREW_DAEMON_HOME and agentsRoot: every profile
|
|
21
22
|
// owned by this OS user must share the same physical-compute safety boundary.
|
|
22
|
-
return resolve(userHome,
|
|
23
|
+
return resolve(localHome ?? resolveDaemonLocalStateRoot({ userHome, platform: process.platform }), "daemon", "compute-execution");
|
|
23
24
|
}
|
|
24
25
|
async function syncDirectory(path) {
|
|
25
26
|
if (process.platform === "win32")
|
|
@@ -132,7 +133,7 @@ async function writeLaunchTimestamp(path, value) {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
export function createHostExecutionCoordinator(options = {}) {
|
|
135
|
-
const root = resolve(options.root ?? defaultHostExecutionCoordinatorRoot());
|
|
136
|
+
const root = resolve(options.root ?? defaultHostExecutionCoordinatorRoot(options.userHome, options.localHome));
|
|
136
137
|
const executionSlots = options.executionSlots ?? HOST_EXECUTION_SLOT_COUNT;
|
|
137
138
|
const startupSlots = options.startupSlots ?? HOST_STARTUP_SLOT_COUNT;
|
|
138
139
|
const startupGapMs = options.startupGapMs ?? HOST_STARTUP_GAP_MS;
|
package/dist/main.js
CHANGED
|
@@ -30,6 +30,7 @@ import { storageMigrationStatusReplay } from "./storage-status-replay.js";
|
|
|
30
30
|
import { runStorageWorkerCommand } from "./storage-worker-command.js";
|
|
31
31
|
import { probeStorageMigrationRuntime } from "./storage-runtime-readiness.js";
|
|
32
32
|
import { createStorageTargetConfirmationHandler } from "./storage-target-confirmation.js";
|
|
33
|
+
import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
|
|
33
34
|
async function main() {
|
|
34
35
|
const workerResult = await runStorageWorkerCommand(process.argv.slice(2));
|
|
35
36
|
if (workerResult !== null) {
|
|
@@ -73,8 +74,16 @@ async function main() {
|
|
|
73
74
|
if (values["daemon-home"])
|
|
74
75
|
process.env.CREW_DAEMON_HOME = values["daemon-home"];
|
|
75
76
|
let activeDaemonHome;
|
|
77
|
+
const localHome = resolveDaemonLocalStateRoot({
|
|
78
|
+
userHome: homedir(),
|
|
79
|
+
platform: process.platform,
|
|
80
|
+
explicit: process.env.NOWWORK_LOCAL_HOME,
|
|
81
|
+
mode: process.env.NOWWORK_LOCAL_HOME_MODE
|
|
82
|
+
?? process.env.NOWWORK_STORAGE_MODE
|
|
83
|
+
?? (values["daemon-home"] === undefined ? undefined : "legacy"),
|
|
84
|
+
});
|
|
76
85
|
if (values.profile) {
|
|
77
|
-
const home = daemonHome();
|
|
86
|
+
const home = daemonHome(process.env, values.profile);
|
|
78
87
|
activeDaemonHome = home;
|
|
79
88
|
const profile = await loadProfile(values.profile, home);
|
|
80
89
|
if (cmd === "serve") {
|
|
@@ -83,6 +92,7 @@ async function main() {
|
|
|
83
92
|
platform: process.platform,
|
|
84
93
|
profile: profile.name,
|
|
85
94
|
userHome: homedir(),
|
|
95
|
+
localHome,
|
|
86
96
|
uid: process.getuid?.(),
|
|
87
97
|
daemonHome: home,
|
|
88
98
|
agentsRoot: resolveAgentsRoot(profile.agentsRoot, homedir(), process.platform),
|
|
@@ -141,7 +151,7 @@ async function main() {
|
|
|
141
151
|
: daemonGlobalInstallation(builtDaemonEntry(), process.platform);
|
|
142
152
|
const installationLease = installation === null
|
|
143
153
|
? null
|
|
144
|
-
: await acquireDaemonInstallationLease(installation.npmPrefix);
|
|
154
|
+
: await acquireDaemonInstallationLease(installation.npmPrefix, { localHome });
|
|
145
155
|
const migrationRuntime = values.profile === undefined || activeDaemonHome === undefined || storageRuntime !== null
|
|
146
156
|
? { ready: false, reason: "not_legacy_source" }
|
|
147
157
|
: await probeStorageMigrationRuntime({
|
|
@@ -198,6 +208,7 @@ async function main() {
|
|
|
198
208
|
await runWithDaemonInstallationLease(installationLease, async () => {
|
|
199
209
|
const service = serve(runtimeConfig, {
|
|
200
210
|
...(values.profile === undefined ? {} : { profileName: values.profile }),
|
|
211
|
+
localHome,
|
|
201
212
|
...(storageMigration === undefined ? {} : { storageMigration }),
|
|
202
213
|
});
|
|
203
214
|
await runServeLifecycle(service);
|
|
@@ -16,6 +16,7 @@ export async function inspectManagedServiceDiagnostics(input) {
|
|
|
16
16
|
const inspectLease = input.inspectInstallationLease
|
|
17
17
|
?? (() => inspectDaemonInstallationLease(input.expectedRecord.npmPrefix, {
|
|
18
18
|
...(userHome === undefined ? {} : { userHome }),
|
|
19
|
+
...(registry.localHome === undefined ? {} : { localHome: registry.localHome }),
|
|
19
20
|
}));
|
|
20
21
|
let services = null;
|
|
21
22
|
let registryCheck;
|
|
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { acquireProfileSaveLock } from "./computer-profile-lock.js";
|
|
8
|
+
import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
|
|
8
9
|
const AbsolutePathSchema = z.string().min(1).refine(isAbsolute, "must be an absolute path");
|
|
9
10
|
const ManagedServiceRecordSchema = z.object({
|
|
10
11
|
version: z.literal(1),
|
|
@@ -42,11 +43,15 @@ export class ManagedServiceConflictError extends ManagedServiceRegistryError {
|
|
|
42
43
|
this.owner = owner;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
function registryDirectory(userHome) {
|
|
46
|
-
|
|
46
|
+
function registryDirectory(userHome, localHome) {
|
|
47
|
+
const root = localHome ?? resolveDaemonLocalStateRoot({
|
|
48
|
+
userHome,
|
|
49
|
+
platform: process.platform,
|
|
50
|
+
});
|
|
51
|
+
return resolve(root, "daemon", "managed-services");
|
|
47
52
|
}
|
|
48
|
-
export function managedServiceRegistryPath(userHome = homedir()) {
|
|
49
|
-
return resolve(registryDirectory(userHome), "registry.json");
|
|
53
|
+
export function managedServiceRegistryPath(userHome = homedir(), localHome) {
|
|
54
|
+
return resolve(registryDirectory(userHome, localHome), "registry.json");
|
|
50
55
|
}
|
|
51
56
|
export async function listManagedServiceDescriptorPaths(platform, userHome = homedir()) {
|
|
52
57
|
const directory = platform === "darwin"
|
|
@@ -219,14 +224,14 @@ export function managedServiceConflict(services, candidate) {
|
|
|
219
224
|
}
|
|
220
225
|
export async function withManagedServiceRegistryLock(options, operation) {
|
|
221
226
|
const userHome = options.userHome ?? homedir();
|
|
222
|
-
const directory = registryDirectory(userHome);
|
|
227
|
+
const directory = registryDirectory(userHome, options.localHome);
|
|
223
228
|
const release = await acquireProfileSaveLock(directory, {
|
|
224
229
|
...(options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs }),
|
|
225
230
|
...(options.lockRetryMs === undefined ? {} : { retryMs: options.lockRetryMs }),
|
|
226
231
|
});
|
|
227
232
|
let operationError;
|
|
228
233
|
try {
|
|
229
|
-
const path = managedServiceRegistryPath(userHome);
|
|
234
|
+
const path = managedServiceRegistryPath(userHome, options.localHome);
|
|
230
235
|
const services = await readRegistryFile(path);
|
|
231
236
|
return await operation({
|
|
232
237
|
services,
|
|
@@ -258,7 +263,7 @@ export async function withManagedServiceRegistryLock(options, operation) {
|
|
|
258
263
|
}
|
|
259
264
|
}
|
|
260
265
|
export async function readManagedServiceRegistry(options = {}) {
|
|
261
|
-
return readRegistryFile(managedServiceRegistryPath(options.userHome ?? homedir()));
|
|
266
|
+
return readRegistryFile(managedServiceRegistryPath(options.userHome ?? homedir(), options.localHome));
|
|
262
267
|
}
|
|
263
268
|
export async function registerManagedService(input, options = {}) {
|
|
264
269
|
const candidate = normalizeManagedServiceRecord(input);
|
|
@@ -17,7 +17,10 @@ export async function assertManagedServiceStartup(input) {
|
|
|
17
17
|
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
18
18
|
});
|
|
19
19
|
const readRegistry = input.readManagedServices
|
|
20
|
-
?? (() => readManagedServiceRegistry({
|
|
20
|
+
?? (() => readManagedServiceRegistry({
|
|
21
|
+
userHome: input.userHome,
|
|
22
|
+
...(input.localHome === undefined ? {} : { localHome: input.localHome }),
|
|
23
|
+
}));
|
|
21
24
|
const listDescriptors = input.listManagedDescriptorPaths
|
|
22
25
|
?? (() => listManagedServiceDescriptorPaths(input.platform, input.userHome));
|
|
23
26
|
const readDescriptor = input.readServiceDescriptor ?? readServiceDescriptor;
|
package/dist/serve.js
CHANGED
|
@@ -53,6 +53,7 @@ import { handleRuntimeHealthProbeFrame } from "./runtime-health-handler.js";
|
|
|
53
53
|
import { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
54
54
|
import { createServeMigrationController, handleMigrationControlMessage } from "./daemon-migration-wiring.js";
|
|
55
55
|
import { createCodexHomeModeController } from "./codex-home-mode-controller.js";
|
|
56
|
+
import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
|
|
56
57
|
import { attachStorageMigrationRuntime, createServeStorageStatusDelivery, handleStorageMigrationControl, replayStorageMigrationStatus, storageMigrationCapabilities, } from "./storage-serve-adapter.js";
|
|
57
58
|
export { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
58
59
|
// normalize.ts 的活动种类 → activity 枚举
|
|
@@ -83,8 +84,10 @@ export function serve(config, opts = {}) {
|
|
|
83
84
|
let detectedExecutionRuntimes = [], executionRuntimesKnown = false;
|
|
84
85
|
let runtimeFacts = null;
|
|
85
86
|
const runtimeProbe = createRuntimeProbeCoordinator();
|
|
87
|
+
const localHome = opts.localHome ?? resolveDaemonLocalStateRootFromEnv();
|
|
86
88
|
const hostCoordinator = opts.execution?.hostCoordinator
|
|
87
89
|
?? createHostExecutionCoordinator({
|
|
90
|
+
localHome,
|
|
88
91
|
executionSlots: config.executionLimits.maxParallelTotal,
|
|
89
92
|
startupSlots: config.executionLimits.maxStartingTotal,
|
|
90
93
|
startupGapMs: config.executionLimits.startupGapMs,
|
|
@@ -825,9 +828,9 @@ export function serve(config, opts = {}) {
|
|
|
825
828
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
826
829
|
const r = msg;
|
|
827
830
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
828
|
-
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
|
|
831
|
+
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
|
|
829
832
|
try {
|
|
830
|
-
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
|
|
833
|
+
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
|
|
831
834
|
machineHelloPublisher.republish();
|
|
832
835
|
}
|
|
833
836
|
catch (error) {
|
package/dist/slog.js
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
|
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
18
|
+
import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
|
|
18
19
|
const FLUSH_INTERVAL_MS = 1500;
|
|
19
20
|
const BATCH_SIZE = 50;
|
|
20
21
|
const SPOOL_MAX_FILES = 50; // spool 目录文件数上限,超出丢最旧(日志非关键数据,防无限膨胀)
|
|
@@ -27,6 +28,7 @@ let flushPromise = null;
|
|
|
27
28
|
let inFlightBatch = [];
|
|
28
29
|
let lastErrorAt = 0;
|
|
29
30
|
let exitHookInstalled = false;
|
|
31
|
+
let localStateRoot;
|
|
30
32
|
function deploymentEnvironment(serverHost) {
|
|
31
33
|
if (serverHost === "nowwork-in.nowcoder.com")
|
|
32
34
|
return "prod";
|
|
@@ -40,12 +42,23 @@ function deploymentEnvironment(serverHost) {
|
|
|
40
42
|
return "unknown";
|
|
41
43
|
}
|
|
42
44
|
function spoolDir() {
|
|
43
|
-
return process.env.CREW_SLS_SPOOL_DIR ?? join(
|
|
45
|
+
return process.env.CREW_SLS_SPOOL_DIR ?? join(localStateRoot ?? resolveDaemonLocalStateRoot({
|
|
46
|
+
userHome: homedir(),
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
explicit: process.env.NOWWORK_LOCAL_HOME,
|
|
49
|
+
mode: process.env.NOWWORK_LOCAL_HOME_MODE ?? process.env.NOWWORK_STORAGE_MODE,
|
|
50
|
+
}), "logs", "sls-spool");
|
|
44
51
|
}
|
|
45
52
|
/** serve/run 启动时调用一次;disabled(CREW_SLS_LOG=off)则保持 no-op。 */
|
|
46
53
|
export function initSlog(serverUrl, machineToken, identity = {}) {
|
|
47
54
|
if (process.env.CREW_SLS_LOG === "off" || process.env.CREW_SLS_LOG === "0")
|
|
48
55
|
return;
|
|
56
|
+
localStateRoot = resolveDaemonLocalStateRoot({
|
|
57
|
+
userHome: homedir(),
|
|
58
|
+
platform: process.platform,
|
|
59
|
+
explicit: process.env.NOWWORK_LOCAL_HOME,
|
|
60
|
+
mode: process.env.NOWWORK_LOCAL_HOME_MODE ?? process.env.NOWWORK_STORAGE_MODE,
|
|
61
|
+
});
|
|
49
62
|
cfg = { serverUrl: serverUrl.replace(/\/+$/, ""), token: machineToken };
|
|
50
63
|
let serverHost;
|
|
51
64
|
try {
|
package/dist/storage-control.js
CHANGED
|
@@ -5,6 +5,8 @@ const IdentitySchema = z.object({
|
|
|
5
5
|
migrationId: z.string().uuid(),
|
|
6
6
|
environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
|
|
7
7
|
workspaceId: z.string().uuid(),
|
|
8
|
+
ownerEpoch: z.number().int().positive().safe().optional(),
|
|
9
|
+
ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
|
|
8
10
|
});
|
|
9
11
|
const DoctorRequestSchema = IdentitySchema.extend({ type: z.literal("daemon:storage-doctor") }).strict();
|
|
10
12
|
const MigrateRequestSchema = IdentitySchema.extend({
|
|
@@ -26,6 +28,11 @@ export function createStorageControlController(input) {
|
|
|
26
28
|
const profile = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u).parse(input.profile);
|
|
27
29
|
const sourceStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.sourceStorageFingerprint);
|
|
28
30
|
const targetStorageFingerprint = z.string().regex(/^sha256:[0-9a-f]{64}$/u).parse(input.targetStorageFingerprint);
|
|
31
|
+
const ownership = input.ownership === undefined ? undefined : Object.freeze({
|
|
32
|
+
ownerEpoch: z.number().int().positive().safe().parse(input.ownership.ownerEpoch),
|
|
33
|
+
ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u)
|
|
34
|
+
.parse(input.ownership.ownershipClaimFingerprint),
|
|
35
|
+
});
|
|
29
36
|
const preflights = new Map();
|
|
30
37
|
const preflightRuns = new Map();
|
|
31
38
|
const appliedMigrationIds = new Set();
|
|
@@ -39,6 +46,7 @@ export function createStorageControlController(input) {
|
|
|
39
46
|
environment,
|
|
40
47
|
workspaceId,
|
|
41
48
|
profile,
|
|
49
|
+
...(ownership === undefined ? {} : ownership),
|
|
42
50
|
...extra,
|
|
43
51
|
});
|
|
44
52
|
const failure = (identity, errorCode, error, reopenAdmission = false, extra = {}) => {
|
|
@@ -59,7 +67,10 @@ export function createStorageControlController(input) {
|
|
|
59
67
|
input.onStatusError?.(error, statusFrame);
|
|
60
68
|
}
|
|
61
69
|
};
|
|
62
|
-
const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId
|
|
70
|
+
const matchesIdentity = (identity) => identity.environment === environment && identity.workspaceId === workspaceId
|
|
71
|
+
&& (ownership === undefined
|
|
72
|
+
|| (identity.ownerEpoch === ownership.ownerEpoch
|
|
73
|
+
&& identity.ownershipClaimFingerprint === ownership.ownershipClaimFingerprint));
|
|
63
74
|
const preflightKey = (identity) => `${identity.migrationId}\0${identity.environment}\0${identity.workspaceId}`;
|
|
64
75
|
const runPreflight = (identity, failureFallback = "doctor_failed") => {
|
|
65
76
|
const key = preflightKey(identity);
|
package/dist/storage-identity.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { STORAGE_LAYOUT_VERSION } from "./storage-layout.js";
|
|
3
|
+
import { createStorageOwnershipClaim } from "./storage-ownership.js";
|
|
3
4
|
function fingerprint(namespace, value) {
|
|
4
5
|
return `sha256:${createHash("sha256").update(`${namespace}\0${value}`).digest("hex")}`;
|
|
5
6
|
}
|
|
@@ -14,14 +15,27 @@ export function storageHelloIdentity(input) {
|
|
|
14
15
|
|| installation.runtimeRoot !== layout.runtimeRoot) {
|
|
15
16
|
throw new Error("installation storage identity does not match resolved layout");
|
|
16
17
|
}
|
|
18
|
+
const workspaceRootFingerprint = fingerprint("workspace-root", layout.workspaceRoot);
|
|
19
|
+
let ownership;
|
|
20
|
+
if (input.ownership !== undefined) {
|
|
21
|
+
const { claimFingerprint: reportedFingerprint, ...claimIdentity } = input.ownership;
|
|
22
|
+
ownership = createStorageOwnershipClaim(claimIdentity);
|
|
23
|
+
if (ownership.claimFingerprint !== reportedFingerprint
|
|
24
|
+
|| ownership.workspaceId !== layout.workspaceId
|
|
25
|
+
|| ownership.environment !== layout.environment
|
|
26
|
+
|| ownership.workspaceRootFingerprint !== workspaceRootFingerprint) {
|
|
27
|
+
throw new Error("storage ownership identity does not match resolved layout");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
17
30
|
return Object.freeze({
|
|
18
31
|
storageLayoutVersion: STORAGE_LAYOUT_VERSION,
|
|
19
32
|
storageEnvironment: layout.environment,
|
|
20
33
|
storageWorkspaceId: layout.workspaceId,
|
|
21
34
|
storageIdFingerprint: storageIdFingerprint(installation.storageId),
|
|
22
|
-
workspaceRootFingerprint
|
|
35
|
+
workspaceRootFingerprint,
|
|
23
36
|
daemonRootFingerprint: fingerprint("daemon-root", layout.daemonRoot),
|
|
24
37
|
agentRootFingerprint: fingerprint("agent-root", layout.agentItemsRoot),
|
|
38
|
+
...(ownership === undefined ? {} : { storageOwnership: ownership }),
|
|
25
39
|
...(installation.storageMigrationId === undefined
|
|
26
40
|
? {}
|
|
27
41
|
: { storageMigrationId: installation.storageMigrationId }),
|
|
@@ -27,6 +27,8 @@ const ReceiptSchema = z.object({
|
|
|
27
27
|
environment: z.string().min(1),
|
|
28
28
|
workspaceId: z.string().uuid(),
|
|
29
29
|
profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
|
|
30
|
+
ownerEpoch: z.number().int().positive().safe().optional(),
|
|
31
|
+
ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
|
|
30
32
|
manifestDigest: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
31
33
|
targetFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
32
34
|
status: ReceiptStatusSchema,
|
|
@@ -37,13 +39,23 @@ const ReceiptSchema = z.object({
|
|
|
37
39
|
sourceStopAttempted: z.boolean().optional(),
|
|
38
40
|
sourceStopped: z.boolean().optional(),
|
|
39
41
|
targetTouched: z.boolean().optional(),
|
|
40
|
-
}).strict()
|
|
42
|
+
}).strict().superRefine((receipt, context) => {
|
|
43
|
+
if ((receipt.ownerEpoch === undefined) !== (receipt.ownershipClaimFingerprint === undefined)) {
|
|
44
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "storage receipt ownership identity must be complete" });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
41
47
|
const MigrationLockSchema = z.object({
|
|
42
48
|
version: z.literal(1),
|
|
43
49
|
migrationId: z.string().uuid(),
|
|
44
50
|
pid: z.number().int().positive(),
|
|
45
51
|
createdAt: z.string().datetime({ offset: true }),
|
|
46
52
|
}).strict();
|
|
53
|
+
const REQUIRED_BACKUP_ARTIFACTS = Object.freeze([
|
|
54
|
+
"profile.json",
|
|
55
|
+
"service-descriptor",
|
|
56
|
+
"managed-service-registry.json",
|
|
57
|
+
"installations.json",
|
|
58
|
+
]);
|
|
47
59
|
export class StorageMigrationFailure extends Error {
|
|
48
60
|
code;
|
|
49
61
|
receipt;
|
|
@@ -204,6 +216,16 @@ function assertOwnedStaging(input) {
|
|
|
204
216
|
}
|
|
205
217
|
}
|
|
206
218
|
async function backupArtifacts(input, receipt) {
|
|
219
|
+
const providedNames = new Set(input.backupArtifacts.map((artifact) => artifact.name));
|
|
220
|
+
const missing = REQUIRED_BACKUP_ARTIFACTS.filter((name) => !providedNames.has(name));
|
|
221
|
+
if (missing.length > 0) {
|
|
222
|
+
throw new StorageMigrationFailure("backup_artifact_missing", `required rollback artifacts are missing: ${missing.join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
for (const artifact of input.backupArtifacts) {
|
|
225
|
+
if (!(await lstat(artifact.path)).isFile()) {
|
|
226
|
+
throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
207
229
|
const directory = join(input.backupsRoot, receipt.migrationId);
|
|
208
230
|
const artifactsDirectory = join(directory, "artifacts");
|
|
209
231
|
await mkdir(artifactsDirectory, { recursive: true, mode: 0o700 });
|
|
@@ -217,8 +239,6 @@ async function backupArtifacts(input, receipt) {
|
|
|
217
239
|
throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact names must be unique safe file names");
|
|
218
240
|
}
|
|
219
241
|
names.add(artifact.name);
|
|
220
|
-
if (!(await lstat(artifact.path)).isFile())
|
|
221
|
-
throw new StorageMigrationFailure("backup_artifact_invalid", "backup artifact must be a file");
|
|
222
242
|
const destination = join(artifactsDirectory, artifact.name);
|
|
223
243
|
await copyFile(artifact.path, destination);
|
|
224
244
|
await chmod(destination, 0o600);
|
|
@@ -271,6 +291,7 @@ export async function runStorageMigration(input, dependencies) {
|
|
|
271
291
|
environment: input.doctor.environment,
|
|
272
292
|
workspaceId: input.doctor.workspaceId,
|
|
273
293
|
profile: input.doctor.profile,
|
|
294
|
+
...(input.ownership === undefined ? {} : input.ownership),
|
|
274
295
|
manifestDigest: input.doctor.manifestDigest,
|
|
275
296
|
targetFingerprint: targetFingerprint(input.targetWorkspaceRoot),
|
|
276
297
|
status: "preflight_passed",
|
|
@@ -301,7 +322,7 @@ export async function runStorageMigration(input, dependencies) {
|
|
|
301
322
|
input.assertHealthy?.();
|
|
302
323
|
await transition("backing_up");
|
|
303
324
|
receipt = await backupArtifacts(input, receipt).catch((error) => {
|
|
304
|
-
failureCode = "backup_failed";
|
|
325
|
+
failureCode = error instanceof StorageMigrationFailure ? error.code : "backup_failed";
|
|
305
326
|
throw error;
|
|
306
327
|
});
|
|
307
328
|
await writeReceipt(path, receipt);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const FingerprintSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
|
|
6
|
+
const StorageOwnershipClaimSchema = z.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
machineId: z.string().uuid(),
|
|
9
|
+
workspaceId: z.string().uuid(),
|
|
10
|
+
environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
|
|
11
|
+
volumeFingerprint: FingerprintSchema,
|
|
12
|
+
workspaceRootFingerprint: FingerprintSchema,
|
|
13
|
+
ownerEpoch: z.number().int().positive().safe(),
|
|
14
|
+
claimFingerprint: FingerprintSchema,
|
|
15
|
+
}).strict();
|
|
16
|
+
function claimFingerprint(identity) {
|
|
17
|
+
const digest = createHash("sha256").update([
|
|
18
|
+
"nowwork-storage-ownership-v1",
|
|
19
|
+
identity.machineId,
|
|
20
|
+
identity.workspaceId,
|
|
21
|
+
identity.environment,
|
|
22
|
+
identity.volumeFingerprint,
|
|
23
|
+
identity.workspaceRootFingerprint,
|
|
24
|
+
String(identity.ownerEpoch),
|
|
25
|
+
].join("\0")).digest("hex");
|
|
26
|
+
return `sha256:${digest}`;
|
|
27
|
+
}
|
|
28
|
+
export function createStorageOwnershipClaim(input) {
|
|
29
|
+
const parsed = StorageOwnershipClaimSchema.omit({ version: true, claimFingerprint: true }).parse(input);
|
|
30
|
+
const identity = Object.freeze({
|
|
31
|
+
...parsed,
|
|
32
|
+
machineId: parsed.machineId.toLowerCase(),
|
|
33
|
+
workspaceId: parsed.workspaceId.toLowerCase(),
|
|
34
|
+
environment: parsed.environment.toLowerCase(),
|
|
35
|
+
});
|
|
36
|
+
return Object.freeze({ ...identity, claimFingerprint: claimFingerprint(identity) });
|
|
37
|
+
}
|
|
38
|
+
export function storageOwnershipClaimPath(nowworkHome) {
|
|
39
|
+
return join(nowworkHome, "storage-layout.json");
|
|
40
|
+
}
|
|
41
|
+
export async function readStorageOwnershipClaim(nowworkHome) {
|
|
42
|
+
const path = storageOwnershipClaimPath(nowworkHome);
|
|
43
|
+
const metadata = await lstat(path);
|
|
44
|
+
if (!metadata.isFile())
|
|
45
|
+
throw new Error("storage ownership claim must be a file");
|
|
46
|
+
if (metadata.size > 16 * 1024)
|
|
47
|
+
throw new Error("storage ownership claim file is too large");
|
|
48
|
+
const parsed = StorageOwnershipClaimSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
49
|
+
const { version: _version, claimFingerprint: reportedFingerprint, ...identity } = parsed;
|
|
50
|
+
const expected = createStorageOwnershipClaim(identity);
|
|
51
|
+
if (reportedFingerprint !== expected.claimFingerprint) {
|
|
52
|
+
throw new Error("storage ownership claim fingerprint is invalid");
|
|
53
|
+
}
|
|
54
|
+
return expected;
|
|
55
|
+
}
|
|
@@ -23,9 +23,10 @@ function deterministicUuid(value) {
|
|
|
23
23
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
24
24
|
}
|
|
25
25
|
function storageId(input, workspaceId) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
if (input.ownership.workspaceId !== workspaceId || input.ownership.environment !== input.environment) {
|
|
27
|
+
throw new Error("Storage ownership claim does not match the migration identity");
|
|
28
|
+
}
|
|
29
|
+
return `storage_${input.ownership.claimFingerprint.slice("sha256:".length)}`;
|
|
29
30
|
}
|
|
30
31
|
function installationBase(input, workspaceId, migrationId, createdAt) {
|
|
31
32
|
const layout = storageLayout({
|
|
@@ -102,6 +103,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
102
103
|
...injected,
|
|
103
104
|
};
|
|
104
105
|
let workspaceId = null;
|
|
106
|
+
let machineId = null;
|
|
105
107
|
let identity;
|
|
106
108
|
let gate = null;
|
|
107
109
|
let sendStatus = null;
|
|
@@ -194,6 +196,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
194
196
|
targetWorkspaceRoot: target.workspaceRoot,
|
|
195
197
|
backupsRoot: input.local.backupsRoot,
|
|
196
198
|
backupArtifacts: input.backupArtifacts.map((artifact) => ({ ...artifact })),
|
|
199
|
+
ownership: {
|
|
200
|
+
ownerEpoch: input.ownership.ownerEpoch,
|
|
201
|
+
ownershipClaimFingerprint: input.ownership.claimFingerprint,
|
|
202
|
+
},
|
|
197
203
|
acceptedWarnings: [...input.policy.acceptedWarnings],
|
|
198
204
|
},
|
|
199
205
|
service: {
|
|
@@ -229,6 +235,10 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
229
235
|
profile: input.profile,
|
|
230
236
|
sourceStorageFingerprint,
|
|
231
237
|
targetStorageFingerprint: identity.storageIdFingerprint,
|
|
238
|
+
ownership: {
|
|
239
|
+
ownerEpoch: input.ownership.ownerEpoch,
|
|
240
|
+
ownershipClaimFingerprint: input.ownership.claimFingerprint,
|
|
241
|
+
},
|
|
232
242
|
runDoctor: async (migrationId) => dependencies.runDoctor(await doctorInput(migrationId)),
|
|
233
243
|
runMigration: async (doctor) => {
|
|
234
244
|
await gate.closeAndFlush();
|
|
@@ -264,6 +274,7 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
264
274
|
environment: operation.environment,
|
|
265
275
|
workspaceId: operation.workspaceId,
|
|
266
276
|
profile: operation.profile,
|
|
277
|
+
...(operation.migration.ownership === undefined ? {} : operation.migration.ownership),
|
|
267
278
|
}, sendStatus);
|
|
268
279
|
if (outcome.reopenAdmission)
|
|
269
280
|
gate.reopen();
|
|
@@ -285,20 +296,31 @@ export function createStorageMigrationRuntimeController(rawInput, injected = {})
|
|
|
285
296
|
};
|
|
286
297
|
const bindServerIdentity = (server) => {
|
|
287
298
|
const nextWorkspaceId = UUID.parse(server.workspaceId).toLowerCase();
|
|
299
|
+
const nextMachineId = UUID.parse(server.machineId).toLowerCase();
|
|
288
300
|
if (workspaceId !== null && workspaceId !== nextWorkspaceId) {
|
|
289
301
|
throw new Error("Storage migration server workspace identity changed");
|
|
290
302
|
}
|
|
291
|
-
|
|
303
|
+
if (machineId !== null && machineId !== nextMachineId) {
|
|
304
|
+
throw new Error("Storage migration server machine identity changed");
|
|
305
|
+
}
|
|
306
|
+
if (input.ownership.workspaceId !== nextWorkspaceId
|
|
307
|
+
|| input.ownership.machineId !== nextMachineId
|
|
308
|
+
|| input.ownership.environment !== input.environment) {
|
|
309
|
+
throw new Error("Storage ownership claim does not match authenticated server identity");
|
|
310
|
+
}
|
|
292
311
|
const createdAt = dependencies.now().toISOString();
|
|
293
|
-
const installation = installationBase(input,
|
|
312
|
+
const installation = installationBase(input, nextWorkspaceId, undefined, createdAt);
|
|
294
313
|
const layout = storageLayout({
|
|
295
314
|
nowworkHome: input.nowworkHome,
|
|
296
315
|
environment: input.environment,
|
|
297
|
-
workspaceId,
|
|
316
|
+
workspaceId: nextWorkspaceId,
|
|
298
317
|
runtimeBase: input.local.runtimeBase,
|
|
299
318
|
platform: input.platform,
|
|
300
319
|
});
|
|
301
|
-
|
|
320
|
+
const nextIdentity = storageHelloIdentity({ installation, layout, ownership: input.ownership });
|
|
321
|
+
workspaceId = nextWorkspaceId;
|
|
322
|
+
machineId = nextMachineId;
|
|
323
|
+
identity = nextIdentity;
|
|
302
324
|
ensureControl();
|
|
303
325
|
drainPendingControlMessages();
|
|
304
326
|
};
|
|
@@ -5,10 +5,12 @@ import { buildServiceSpec } from "./computer-service.js";
|
|
|
5
5
|
import { profilePath } from "./computer-profile.js";
|
|
6
6
|
import { assertManagedServiceIdentity } from "./managed-service-lifecycle.js";
|
|
7
7
|
import { managedServiceRegistryPath, readManagedServiceRegistry, } from "./managed-service-registry.js";
|
|
8
|
-
import { resolveLocalStorageRoots } from "./storage-config.js";
|
|
8
|
+
import { installationsPath, resolveLocalStorageRoots } from "./storage-config.js";
|
|
9
9
|
import { readStorageMigrationPolicy } from "./storage-migration-policy.js";
|
|
10
10
|
import { createStorageMigrationRuntimeController, } from "./storage-runtime-controller.js";
|
|
11
11
|
import { resolveEnvTag, resolveNowworkHome } from "./storage-layout.js";
|
|
12
|
+
import { readStorageOwnershipClaim } from "./storage-ownership.js";
|
|
13
|
+
import { resolveDaemonLocalStateRoot } from "./daemon-local-home.js";
|
|
12
14
|
const notReady = (reason) => Object.freeze({ ready: false, reason });
|
|
13
15
|
function canonicalPath(value) {
|
|
14
16
|
const absolute = resolve(value);
|
|
@@ -73,6 +75,12 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
73
75
|
serverUrl: z.string().url().parse(rawInput.serverUrl),
|
|
74
76
|
platform: rawInput.platform,
|
|
75
77
|
};
|
|
78
|
+
const localHome = resolveDaemonLocalStateRoot({
|
|
79
|
+
userHome: rawInput.userHome,
|
|
80
|
+
platform: rawInput.platform,
|
|
81
|
+
explicit: rawInput.environment.NOWWORK_LOCAL_HOME,
|
|
82
|
+
mode: rawInput.environment.NOWWORK_LOCAL_HOME_MODE ?? rawInput.environment.NOWWORK_STORAGE_MODE,
|
|
83
|
+
});
|
|
76
84
|
let nowworkHome;
|
|
77
85
|
let environment;
|
|
78
86
|
try {
|
|
@@ -106,11 +114,12 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
106
114
|
assertManagedServiceIdentity,
|
|
107
115
|
readPolicy: readStorageMigrationPolicy,
|
|
108
116
|
createController: createStorageMigrationRuntimeController,
|
|
117
|
+
readOwnership: readStorageOwnershipClaim,
|
|
109
118
|
...injected,
|
|
110
119
|
};
|
|
111
120
|
let sourceRecord;
|
|
112
121
|
try {
|
|
113
|
-
const records = await dependencies.readManagedServices({ userHome: input.userHome });
|
|
122
|
+
const records = await dependencies.readManagedServices({ userHome: input.userHome, localHome });
|
|
114
123
|
const found = findSourceRecord(records, input);
|
|
115
124
|
if (found === null)
|
|
116
125
|
return notReady("source_service_record_missing");
|
|
@@ -132,7 +141,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
132
141
|
const assertSourceIdentity = () => dependencies.assertManagedServiceIdentity({
|
|
133
142
|
spec: sourceSpec,
|
|
134
143
|
record: sourceRecord,
|
|
135
|
-
registry: { userHome: input.userHome },
|
|
144
|
+
registry: { userHome: input.userHome, localHome },
|
|
136
145
|
});
|
|
137
146
|
try {
|
|
138
147
|
await assertSourceIdentity();
|
|
@@ -153,6 +162,17 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
153
162
|
catch {
|
|
154
163
|
return notReady("migration_policy_invalid");
|
|
155
164
|
}
|
|
165
|
+
let ownership;
|
|
166
|
+
try {
|
|
167
|
+
ownership = await dependencies.readOwnership(nowworkHome);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return notReady(error.code === "ENOENT"
|
|
171
|
+
? "storage_ownership_claim_missing"
|
|
172
|
+
: "storage_ownership_claim_invalid");
|
|
173
|
+
}
|
|
174
|
+
if (ownership.environment !== environment)
|
|
175
|
+
return notReady("storage_ownership_claim_invalid");
|
|
156
176
|
const local = resolveLocalStorageRoots({
|
|
157
177
|
platform: input.platform,
|
|
158
178
|
userHome: input.userHome,
|
|
@@ -169,6 +189,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
169
189
|
sourceRecord,
|
|
170
190
|
local,
|
|
171
191
|
policy,
|
|
192
|
+
ownership,
|
|
172
193
|
serviceEnvironment: {
|
|
173
194
|
NOWWORK_HOME: nowworkHome,
|
|
174
195
|
NOWWORK_ENV_TAG: environment,
|
|
@@ -177,7 +198,8 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
177
198
|
backupArtifacts: [
|
|
178
199
|
{ name: "profile.json", path: profilePath(input.profile, input.currentDaemonHome) },
|
|
179
200
|
{ name: "service-descriptor", path: sourceRecord.descriptorPath },
|
|
180
|
-
{ name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome) },
|
|
201
|
+
{ name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome, localHome) },
|
|
202
|
+
{ name: "installations.json", path: installationsPath(local.configRoot) },
|
|
181
203
|
],
|
|
182
204
|
}, { assertSourceIdentity });
|
|
183
205
|
return Object.freeze({ ready: true, runtime });
|
package/dist/storage-runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { findInstallationForProfile, readInstallations, resolveLocalStorageRoots, } from "./storage-config.js";
|
|
2
2
|
import { storageHelloIdentity } from "./storage-identity.js";
|
|
3
3
|
import { storageLayout } from "./storage-layout.js";
|
|
4
|
+
import { readStorageOwnershipClaim } from "./storage-ownership.js";
|
|
4
5
|
const canonicalServerUrl = (value) => value.replace(/\/+$/u, "");
|
|
5
6
|
export function resolveStorageRuntime(input) {
|
|
6
7
|
const installation = findInstallationForProfile(input.records, input.profile);
|
|
@@ -24,7 +25,11 @@ export function resolveStorageRuntime(input) {
|
|
|
24
25
|
return Object.freeze({
|
|
25
26
|
installation,
|
|
26
27
|
layout,
|
|
27
|
-
identity: storageHelloIdentity({
|
|
28
|
+
identity: storageHelloIdentity({
|
|
29
|
+
installation,
|
|
30
|
+
layout,
|
|
31
|
+
...(input.ownership === undefined ? {} : { ownership: input.ownership }),
|
|
32
|
+
}),
|
|
28
33
|
local,
|
|
29
34
|
});
|
|
30
35
|
}
|
|
@@ -35,5 +40,16 @@ export async function loadStorageRuntime(input) {
|
|
|
35
40
|
environment: input.environment,
|
|
36
41
|
});
|
|
37
42
|
const records = await readInstallations({ configRoot: local.configRoot });
|
|
38
|
-
|
|
43
|
+
const baseline = resolveStorageRuntime({ ...input, records });
|
|
44
|
+
if (baseline === null)
|
|
45
|
+
return null;
|
|
46
|
+
let ownership;
|
|
47
|
+
try {
|
|
48
|
+
ownership = await readStorageOwnershipClaim(baseline.layout.persistentRoot);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code !== "ENOENT")
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return ownership === undefined ? baseline : resolveStorageRuntime({ ...input, records, ownership });
|
|
39
55
|
}
|