@nowcrew/daemon 0.6.77-beta.7 → 0.6.77-beta.8
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 +20 -0
- package/dist/automatic-daemon-installer.js +7 -0
- package/dist/automatic-install-target.js +34 -9
- package/dist/computer-cli.js +25 -3
- package/dist/computer-profile.js +15 -2
- package/dist/daemon-local-home.js +73 -0
- package/dist/daemon-migration-controller.js +9 -1
- package/dist/main.js +1 -1
- package/dist/serve.js +2 -2
- 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 +16 -1
- 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 +1 -1
package/README.md
CHANGED
|
@@ -479,6 +479,26 @@ failure matrix, rollout limits, and the server-native follow-up design.
|
|
|
479
479
|
|
|
480
480
|
## Code Map
|
|
481
481
|
|
|
482
|
+
## Local daemon installation root (experimental)
|
|
483
|
+
|
|
484
|
+
New installations use `~/.nowwork` for machine-local daemon configuration and npm runtimes. The
|
|
485
|
+
Workspace persistence root is still selected independently by `NOWWORK_HOME`; it may be a local
|
|
486
|
+
directory or an ECS-specific NAS directory. Runtime packages, locks, sockets, PIDs and temporary
|
|
487
|
+
files must remain on the machine running the daemon.
|
|
488
|
+
|
|
489
|
+
The selector is controlled by `NOWWORK_LOCAL_HOME_MODE`:
|
|
490
|
+
|
|
491
|
+
- `auto` (default): use an existing `.nowwork` installation, otherwise preserve an existing `.crew`
|
|
492
|
+
installation; a brand-new profile is created under `.nowwork`.
|
|
493
|
+
- `nowwork`: require the `.nowwork` installation root.
|
|
494
|
+
- `legacy`: force the old `.crew` root for rollback.
|
|
495
|
+
|
|
496
|
+
`NOWWORK_STORAGE_MODE` is accepted as a compatibility alias. `NOWWORK_LOCAL_HOME` can override the
|
|
497
|
+
local root and should point to a local filesystem, never to the NAS Workspace root; the mount/device
|
|
498
|
+
check remains a doctor/migration gate. If the same profile
|
|
499
|
+
is found in both roots, startup/install is refused until the duplicate is resolved explicitly. The
|
|
500
|
+
old `.crew` tree is not deleted by this compatibility layer.
|
|
501
|
+
|
|
482
502
|
- `src/serve.ts`: connection, negotiation, routing, sync, and legacy boundary.
|
|
483
503
|
- `src/daemon-update-eligibility.ts`, `src/daemon-updater.ts`, and `src/daemon-update-controller.ts`:
|
|
484
504
|
managed-update eligibility, exact npm replacement, host-wide admission barrier, and restart handoff.
|
|
@@ -39,6 +39,7 @@ function defaultDependencies() {
|
|
|
39
39
|
platform,
|
|
40
40
|
userHome,
|
|
41
41
|
uid: process.getuid?.(),
|
|
42
|
+
environment: process.env,
|
|
42
43
|
nodePath: process.execPath,
|
|
43
44
|
version: daemonVersion(),
|
|
44
45
|
...(process.env.PATH ? { runtimePath: process.env.PATH } : {}),
|
|
@@ -231,6 +232,10 @@ function recoveryError(primary, failures) {
|
|
|
231
232
|
}
|
|
232
233
|
export async function installDaemonAutomatically(input, dependencies = defaultDependencies()) {
|
|
233
234
|
const machineToken = MachineTokenSchema.parse(input.machineToken);
|
|
235
|
+
const requestedLocalHome = input.requestedLocalHome ?? dependencies.environment.NOWWORK_LOCAL_HOME;
|
|
236
|
+
const requestedLocalHomeMode = input.requestedLocalHomeMode
|
|
237
|
+
?? dependencies.environment.NOWWORK_LOCAL_HOME_MODE
|
|
238
|
+
?? dependencies.environment.NOWWORK_STORAGE_MODE;
|
|
234
239
|
const target = resolveAutomaticInstallTarget({
|
|
235
240
|
serverUrl: input.serverUrl,
|
|
236
241
|
...(input.requestedProfile === undefined ? {} : { requestedProfile: input.requestedProfile }),
|
|
@@ -238,6 +243,8 @@ export async function installDaemonAutomatically(input, dependencies = defaultDe
|
|
|
238
243
|
...(input.requestedRegistryUrl === undefined ? {} : { requestedRegistryUrl: input.requestedRegistryUrl }),
|
|
239
244
|
userHome: dependencies.userHome,
|
|
240
245
|
platform: dependencies.platform,
|
|
246
|
+
...(requestedLocalHome === undefined ? {} : { requestedLocalHome }),
|
|
247
|
+
...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
|
|
241
248
|
});
|
|
242
249
|
if (!isDaemonReleaseVersion(dependencies.version)) {
|
|
243
250
|
throw new Error(`Invoking daemon package has an invalid release version '${dependencies.version}'`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { posix, win32 } from "node:path";
|
|
2
2
|
import { profileLayout, validateLocalInstanceName } from "./profile-layout.js";
|
|
3
3
|
import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
|
|
4
|
+
import { resolveDaemonLocalHome } from "./daemon-local-home.js";
|
|
4
5
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
5
6
|
const KNOWN_TARGETS = {
|
|
6
7
|
"nowwork-in.nowcoder.com": {
|
|
@@ -72,13 +73,21 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
|
|
|
72
73
|
const legacyProfile = environment === "production" ? "in" : environment;
|
|
73
74
|
assertReleaseTagForServerUrl(input.serverUrl, input.requestedReleaseTag);
|
|
74
75
|
if (requestedProfile === undefined || requestedProfile === legacyProfile) {
|
|
76
|
+
const local = resolveDaemonLocalHome({
|
|
77
|
+
userHome: input.userHome,
|
|
78
|
+
platform: input.platform,
|
|
79
|
+
profile: legacyProfile,
|
|
80
|
+
...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
|
|
81
|
+
...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
|
|
82
|
+
...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
|
|
83
|
+
});
|
|
75
84
|
return Object.freeze({
|
|
76
85
|
kind: "known",
|
|
77
86
|
profile: legacyProfile,
|
|
78
87
|
serverUrl: normalizedServerUrl(parsed),
|
|
79
|
-
daemonHome:
|
|
80
|
-
agentsRoot: paths.join(
|
|
81
|
-
npmPrefix:
|
|
88
|
+
daemonHome: local.daemonHome,
|
|
89
|
+
agentsRoot: paths.join(local.root, environment === "production" ? "agents" : "agents-dev"),
|
|
90
|
+
npmPrefix: local.runtimePrefix,
|
|
82
91
|
releaseTag: environment === "dev" ? "beta" : "latest",
|
|
83
92
|
});
|
|
84
93
|
}
|
|
@@ -99,15 +108,23 @@ function knownTargetForProfile(environment, requestedProfile, input, parsed) {
|
|
|
99
108
|
userHome: input.userHome,
|
|
100
109
|
platform: input.platform,
|
|
101
110
|
});
|
|
111
|
+
const local = resolveDaemonLocalHome({
|
|
112
|
+
userHome: input.userHome,
|
|
113
|
+
platform: input.platform,
|
|
114
|
+
profile: layout.profile,
|
|
115
|
+
...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
|
|
116
|
+
...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
|
|
117
|
+
...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
|
|
118
|
+
});
|
|
102
119
|
return Object.freeze({
|
|
103
120
|
kind: "known",
|
|
104
121
|
environment,
|
|
105
122
|
localInstanceName,
|
|
106
123
|
profile: layout.profile,
|
|
107
124
|
serverUrl: normalizedServerUrl(parsed),
|
|
108
|
-
daemonHome:
|
|
109
|
-
agentsRoot: layout.
|
|
110
|
-
npmPrefix:
|
|
125
|
+
daemonHome: local.daemonHome,
|
|
126
|
+
agentsRoot: paths.join(local.root, "agents-" + layout.environment + "-" + layout.localInstanceName),
|
|
127
|
+
npmPrefix: local.runtimePrefix,
|
|
111
128
|
releaseTag: environment === "dev" ? "beta" : "latest",
|
|
112
129
|
});
|
|
113
130
|
}
|
|
@@ -127,13 +144,21 @@ export function resolveAutomaticInstallTarget(input) {
|
|
|
127
144
|
return registryUrl === undefined ? target : Object.freeze({ ...target, registryUrl });
|
|
128
145
|
}
|
|
129
146
|
const profile = requireCustomProfile(hostname, input.requestedProfile);
|
|
147
|
+
const local = resolveDaemonLocalHome({
|
|
148
|
+
userHome: input.userHome,
|
|
149
|
+
platform: input.platform,
|
|
150
|
+
profile,
|
|
151
|
+
...(input.requestedLocalHome === undefined ? {} : { explicit: input.requestedLocalHome }),
|
|
152
|
+
...(input.requestedLocalHomeMode === undefined ? {} : { mode: input.requestedLocalHomeMode }),
|
|
153
|
+
...(input.pathExists === undefined ? {} : { exists: input.pathExists }),
|
|
154
|
+
});
|
|
130
155
|
return Object.freeze({
|
|
131
156
|
kind: "custom",
|
|
132
157
|
profile,
|
|
133
158
|
serverUrl: normalizedServerUrl(parsed),
|
|
134
|
-
daemonHome:
|
|
135
|
-
agentsRoot: paths.join(
|
|
136
|
-
npmPrefix:
|
|
159
|
+
daemonHome: local.daemonHome,
|
|
160
|
+
agentsRoot: paths.join(local.root, `agents-${profile}`),
|
|
161
|
+
npmPrefix: local.runtimePrefix,
|
|
137
162
|
releaseTag: input.requestedReleaseTag ?? "latest",
|
|
138
163
|
...(registryUrl === undefined ? {} : { registryUrl }),
|
|
139
164
|
});
|
package/dist/computer-cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { posix, resolve, win32 } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { assertProfileAgentsRootUnique, daemonHome, inspectProfileAgentsRoot, listProfiles, loadProfile, PROFILE_AGENTS_ROOT_CONFLICT_MESSAGE, ProfileAgentsRootConflictError, profileIsPrivate, profilePath, publicProfile, removeProfile, resolveAgentsRoot, saveProfile, windowsDpapiProtector, } from "./computer-profile.js";
|
|
6
6
|
import { buildServiceSpec, doctorService, hardenWindowsProfile, installService, readServiceDescriptor, serviceAction, serviceStatus, systemCommandRunner, uninstallService, upgradeDaemon, windowsProfileIsPrivate, } from "./computer-service.js";
|
|
@@ -18,6 +18,7 @@ import { assertManagedServiceIdentity, buildManagedServiceRecord, installManaged
|
|
|
18
18
|
import { inspectManagedServiceDiagnostics } from "./managed-service-diagnostics.js";
|
|
19
19
|
import { readManagedServiceRegistry } from "./managed-service-registry.js";
|
|
20
20
|
import { installDaemonAutomatically, } from "./automatic-daemon-installer.js";
|
|
21
|
+
import { resolveDaemonLocalHome } from "./daemon-local-home.js";
|
|
21
22
|
const COMPUTER_COMMANDS = new Set(["profile", "doctor", "install", "uninstall", "start", "stop", "restart", "status", "upgrade"]);
|
|
22
23
|
export function builtDaemonEntry(moduleUrl = import.meta.url) {
|
|
23
24
|
return moduleUrl.endsWith(".js")
|
|
@@ -67,6 +68,7 @@ function usage(td) {
|
|
|
67
68
|
" crew-daemon install --server-url <url> --api-key <sk_machine_*> [--profile <custom-name>] [--release-tag <latest|beta>] [--registry <https-url>]",
|
|
68
69
|
" crew-daemon doctor|install|uninstall|start|stop|restart|status --profile <name>",
|
|
69
70
|
" crew-daemon upgrade [--profile <name>] [--release-tag <latest|beta>] [--registry <https-url>]",
|
|
71
|
+
" NOWWORK_LOCAL_HOME_MODE=auto|nowwork|legacy NOWWORK_LOCAL_HOME=~/.nowwork",
|
|
70
72
|
"",
|
|
71
73
|
td("profile save reads the machine token from CREW_MACHINE_TOKEN, or stdin with --token-stdin."),
|
|
72
74
|
].join("\n") + "\n";
|
|
@@ -154,6 +156,7 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
154
156
|
?? deps.env.CREW_MACHINE_TOKEN)?.trim();
|
|
155
157
|
if (!machineToken)
|
|
156
158
|
throw new Error(td("Missing machine token for automatic installation"));
|
|
159
|
+
const requestedLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
|
|
157
160
|
const result = await deps.installAutomatically({
|
|
158
161
|
serverUrl: parsed.values["server-url"],
|
|
159
162
|
machineToken,
|
|
@@ -162,11 +165,28 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
162
165
|
...(configuredRegistryUrl === undefined
|
|
163
166
|
? {}
|
|
164
167
|
: { requestedRegistryUrl: configuredRegistryUrl }),
|
|
168
|
+
...(deps.env.NOWWORK_LOCAL_HOME === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { requestedLocalHome: deps.env.NOWWORK_LOCAL_HOME }),
|
|
171
|
+
...(requestedLocalHomeMode === undefined ? {} : { requestedLocalHomeMode }),
|
|
165
172
|
});
|
|
166
173
|
deps.stdout(`${td("Installed daemon {{version}} for profile '{{profile}}'; service '{{service}}' is running.", { version: result.version, profile: result.profile, service: result.serviceId })}\n`);
|
|
167
174
|
return 0;
|
|
168
175
|
}
|
|
169
|
-
const
|
|
176
|
+
const resolvedProfile = profileName ?? positionalName;
|
|
177
|
+
const configuredLocalHomeMode = deps.env.NOWWORK_LOCAL_HOME_MODE ?? deps.env.NOWWORK_STORAGE_MODE;
|
|
178
|
+
const home = resolvedProfile !== undefined
|
|
179
|
+
&& (deps.env.NOWWORK_LOCAL_HOME !== undefined
|
|
180
|
+
|| deps.env.NOWWORK_LOCAL_HOME_MODE !== undefined
|
|
181
|
+
|| deps.env.NOWWORK_STORAGE_MODE !== undefined)
|
|
182
|
+
? resolveDaemonLocalHome({
|
|
183
|
+
userHome: deps.userHome,
|
|
184
|
+
platform: deps.platform,
|
|
185
|
+
profile: resolvedProfile,
|
|
186
|
+
...(deps.env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: deps.env.NOWWORK_LOCAL_HOME }),
|
|
187
|
+
...(configuredLocalHomeMode === undefined ? {} : { mode: configuredLocalHomeMode }),
|
|
188
|
+
}).daemonHome
|
|
189
|
+
: daemonHome(deps.env);
|
|
170
190
|
const profileStorage = {
|
|
171
191
|
platform: deps.platform,
|
|
172
192
|
userHome: deps.userHome,
|
|
@@ -221,7 +241,9 @@ export async function runComputerCommand(argv, overrides = {}) {
|
|
|
221
241
|
}),
|
|
222
242
|
...(parsed.values["agents-root"]
|
|
223
243
|
? { agentsRoot: resolveAgentsRoot(parsed.values["agents-root"], deps.userHome, deps.platform) }
|
|
224
|
-
:
|
|
244
|
+
: (deps.env.NOWWORK_LOCAL_HOME !== undefined || configuredLocalHomeMode !== undefined)
|
|
245
|
+
? { agentsRoot: resolveAgentsRoot((deps.platform === "win32" ? win32 : posix).join((deps.platform === "win32" ? win32 : posix).dirname(home), `agents-${name}`), deps.userHome, deps.platform) }
|
|
246
|
+
: {}),
|
|
225
247
|
...(deps.env.PATH ? { runtimePath: deps.env.PATH } : {}),
|
|
226
248
|
};
|
|
227
249
|
await saveProfile(profile, home, profileStorage);
|
package/dist/computer-profile.js
CHANGED
|
@@ -9,6 +9,7 @@ import { acquireProfileSaveLock, } from "./computer-profile-lock.js";
|
|
|
9
9
|
import { formatDaemonText } from "./i18n.js";
|
|
10
10
|
import { atomicPrivateWrite } from "./atomic-private-write.js";
|
|
11
11
|
import { normalizeDaemonNpmRegistry } from "./npm-registry.js";
|
|
12
|
+
import { resolveDaemonLocalHome } from "./daemon-local-home.js";
|
|
12
13
|
const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
13
14
|
export const MachineTokenSchema = z.string().startsWith("sk_machine_").max(4096);
|
|
14
15
|
const ServerUrlSchema = z.string().url().max(2048).refine((value) => {
|
|
@@ -94,8 +95,20 @@ export class ProfileAgentsRootConflictError extends Error {
|
|
|
94
95
|
}
|
|
95
96
|
const quotePosixArgument = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
96
97
|
const quotePowerShellArgument = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
97
|
-
export function daemonHome(env = process.env) {
|
|
98
|
-
|
|
98
|
+
export function daemonHome(env = process.env, profile) {
|
|
99
|
+
if (env.CREW_DAEMON_HOME !== undefined)
|
|
100
|
+
return resolve(env.CREW_DAEMON_HOME);
|
|
101
|
+
const mode = env.NOWWORK_LOCAL_HOME_MODE ?? env.NOWWORK_STORAGE_MODE;
|
|
102
|
+
if (profile !== undefined && (env.NOWWORK_LOCAL_HOME !== undefined || mode !== undefined)) {
|
|
103
|
+
return resolveDaemonLocalHome({
|
|
104
|
+
userHome: homedir(),
|
|
105
|
+
platform: process.platform,
|
|
106
|
+
profile,
|
|
107
|
+
...(env.NOWWORK_LOCAL_HOME === undefined ? {} : { explicit: env.NOWWORK_LOCAL_HOME }),
|
|
108
|
+
...(mode === undefined ? {} : { mode }),
|
|
109
|
+
}).daemonHome;
|
|
110
|
+
}
|
|
111
|
+
return resolve(homedir(), ".crew/daemon");
|
|
99
112
|
}
|
|
100
113
|
export function resolveAgentsRoot(configured, userHome = homedir(), platform = process.platform) {
|
|
101
114
|
const pathApi = platform === "win32" ? win32 : posix;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
const PROFILE = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
|
|
4
|
+
const CONTROL = /[\0\r\n]/u;
|
|
5
|
+
const pathsFor = (platform) => platform === "win32" ? win32 : posix;
|
|
6
|
+
export function parseDaemonLocalHomeMode(value) {
|
|
7
|
+
if (value === undefined || value.trim() === "")
|
|
8
|
+
return "auto";
|
|
9
|
+
const normalized = value.trim().toLowerCase();
|
|
10
|
+
if (normalized === "auto" || normalized === "nowwork" || normalized === "legacy")
|
|
11
|
+
return normalized;
|
|
12
|
+
throw new Error("NOWWORK_LOCAL_HOME_MODE must be auto, nowwork or legacy");
|
|
13
|
+
}
|
|
14
|
+
function absoluteRoot(value, userHome, platform) {
|
|
15
|
+
const paths = pathsFor(platform);
|
|
16
|
+
if (value.length === 0 || CONTROL.test(value))
|
|
17
|
+
throw new Error("NOWWORK_LOCAL_HOME must be a non-empty single-line path");
|
|
18
|
+
const expanded = value === "~"
|
|
19
|
+
? userHome
|
|
20
|
+
: value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))
|
|
21
|
+
? paths.join(userHome, value.slice(2))
|
|
22
|
+
: value;
|
|
23
|
+
if (!paths.isAbsolute(expanded))
|
|
24
|
+
throw new Error("NOWWORK_LOCAL_HOME must be absolute or start with '~/'");
|
|
25
|
+
return paths.normalize(expanded);
|
|
26
|
+
}
|
|
27
|
+
function legacyDaemonDirectory(profile) {
|
|
28
|
+
if (profile === "in" || profile === "production")
|
|
29
|
+
return "daemon";
|
|
30
|
+
if (profile === "dev")
|
|
31
|
+
return "daemon-dev";
|
|
32
|
+
if (profile.startsWith("nw-"))
|
|
33
|
+
return `daemon-${profile.slice(3)}`;
|
|
34
|
+
return `daemon-${profile}`;
|
|
35
|
+
}
|
|
36
|
+
function resolution(mode, root, profile, platform, source) {
|
|
37
|
+
const paths = pathsFor(platform);
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
mode,
|
|
40
|
+
root,
|
|
41
|
+
daemonHome: mode === "nowwork" ? paths.join(root, "config") : paths.join(root, legacyDaemonDirectory(profile)),
|
|
42
|
+
runtimePrefix: paths.join(root, "daemon-runtimes", profile),
|
|
43
|
+
source,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function resolveDaemonLocalHome(input) {
|
|
47
|
+
if (!PROFILE.test(input.profile))
|
|
48
|
+
throw new Error("daemon profile is invalid");
|
|
49
|
+
const mode = parseDaemonLocalHomeMode(input.mode);
|
|
50
|
+
const paths = pathsFor(input.platform);
|
|
51
|
+
const nowworkRoot = absoluteRoot(input.explicit ?? paths.join(input.userHome, ".nowwork"), input.userHome, input.platform);
|
|
52
|
+
const legacyRoot = absoluteRoot(paths.join(input.userHome, ".crew"), input.userHome, input.platform);
|
|
53
|
+
if (input.explicit !== undefined && mode !== "legacy") {
|
|
54
|
+
return resolution("nowwork", nowworkRoot, input.profile, input.platform, "explicit");
|
|
55
|
+
}
|
|
56
|
+
if (mode === "nowwork")
|
|
57
|
+
return resolution("nowwork", nowworkRoot, input.profile, input.platform, "nowwork_forced");
|
|
58
|
+
if (mode === "legacy")
|
|
59
|
+
return resolution("legacy", legacyRoot, input.profile, input.platform, "legacy_forced");
|
|
60
|
+
const exists = input.exists ?? existsSync;
|
|
61
|
+
const nowwork = resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_nowwork_existing");
|
|
62
|
+
const legacy = resolution("legacy", legacyRoot, input.profile, input.platform, "auto_legacy_existing");
|
|
63
|
+
const hasNowwork = exists(nowwork.runtimePrefix) || exists(paths.join(nowwork.daemonHome, "profiles", `${input.profile}.json`));
|
|
64
|
+
const hasLegacy = exists(legacy.runtimePrefix) || exists(paths.join(legacy.daemonHome, "profiles", `${input.profile}.json`));
|
|
65
|
+
if (hasNowwork && hasLegacy) {
|
|
66
|
+
throw new Error(`Daemon local home conflict: profile '${input.profile}' exists under both .crew and .nowwork`);
|
|
67
|
+
}
|
|
68
|
+
if (hasNowwork)
|
|
69
|
+
return nowwork;
|
|
70
|
+
if (hasLegacy)
|
|
71
|
+
return legacy;
|
|
72
|
+
return resolution("nowwork", nowworkRoot, input.profile, input.platform, "auto_new");
|
|
73
|
+
}
|
|
@@ -125,7 +125,15 @@ export function createDaemonMigrationController(input) {
|
|
|
125
125
|
if (currentProfile.machineToken !== input.machineToken)
|
|
126
126
|
throw new Error("active profile token does not match this daemon");
|
|
127
127
|
currentRegistryUrl = currentProfile.registryUrl;
|
|
128
|
-
const target = resolveAutomaticInstallTarget({
|
|
128
|
+
const target = resolveAutomaticInstallTarget({
|
|
129
|
+
serverUrl: input.serverUrl,
|
|
130
|
+
requestedProfile: message.targetProfile,
|
|
131
|
+
userHome,
|
|
132
|
+
platform,
|
|
133
|
+
// This controller migrates the legacy service layout; the new local-home
|
|
134
|
+
// standard is handled by the storage migration controller.
|
|
135
|
+
requestedLocalHomeMode: "legacy",
|
|
136
|
+
});
|
|
129
137
|
if (!isAbsolute(message.targetDaemonHome) || !isAbsolute(message.targetAgentsRoot)
|
|
130
138
|
|| resolve(message.targetDaemonHome) !== resolve(target.daemonHome)
|
|
131
139
|
|| resolve(message.targetAgentsRoot) !== resolve(target.agentsRoot))
|
package/dist/main.js
CHANGED
|
@@ -74,7 +74,7 @@ async function main() {
|
|
|
74
74
|
process.env.CREW_DAEMON_HOME = values["daemon-home"];
|
|
75
75
|
let activeDaemonHome;
|
|
76
76
|
if (values.profile) {
|
|
77
|
-
const home = daemonHome();
|
|
77
|
+
const home = daemonHome(process.env, values.profile);
|
|
78
78
|
activeDaemonHome = home;
|
|
79
79
|
const profile = await loadProfile(values.profile, home);
|
|
80
80
|
if (cmd === "serve") {
|
package/dist/serve.js
CHANGED
|
@@ -825,9 +825,9 @@ export function serve(config, opts = {}) {
|
|
|
825
825
|
// ready 帧带 server 视角的 machineId/workspaceId → 作为后续所有日志的默认关联键
|
|
826
826
|
const r = msg;
|
|
827
827
|
setSlogDefaults({ machine_id: r.machineId, workspace_id: r.workspaceId });
|
|
828
|
-
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string") {
|
|
828
|
+
if (opts.storageMigration?.ready === true && typeof r.workspaceId === "string" && typeof r.machineId === "string") {
|
|
829
829
|
try {
|
|
830
|
-
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId });
|
|
830
|
+
opts.storageMigration.bindServerIdentity?.({ workspaceId: r.workspaceId, machineId: r.machineId });
|
|
831
831
|
machineHelloPublisher.republish();
|
|
832
832
|
}
|
|
833
833
|
catch (error) {
|
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,11 @@ 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";
|
|
12
13
|
const notReady = (reason) => Object.freeze({ ready: false, reason });
|
|
13
14
|
function canonicalPath(value) {
|
|
14
15
|
const absolute = resolve(value);
|
|
@@ -106,6 +107,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
106
107
|
assertManagedServiceIdentity,
|
|
107
108
|
readPolicy: readStorageMigrationPolicy,
|
|
108
109
|
createController: createStorageMigrationRuntimeController,
|
|
110
|
+
readOwnership: readStorageOwnershipClaim,
|
|
109
111
|
...injected,
|
|
110
112
|
};
|
|
111
113
|
let sourceRecord;
|
|
@@ -153,6 +155,17 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
153
155
|
catch {
|
|
154
156
|
return notReady("migration_policy_invalid");
|
|
155
157
|
}
|
|
158
|
+
let ownership;
|
|
159
|
+
try {
|
|
160
|
+
ownership = await dependencies.readOwnership(nowworkHome);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return notReady(error.code === "ENOENT"
|
|
164
|
+
? "storage_ownership_claim_missing"
|
|
165
|
+
: "storage_ownership_claim_invalid");
|
|
166
|
+
}
|
|
167
|
+
if (ownership.environment !== environment)
|
|
168
|
+
return notReady("storage_ownership_claim_invalid");
|
|
156
169
|
const local = resolveLocalStorageRoots({
|
|
157
170
|
platform: input.platform,
|
|
158
171
|
userHome: input.userHome,
|
|
@@ -169,6 +182,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
169
182
|
sourceRecord,
|
|
170
183
|
local,
|
|
171
184
|
policy,
|
|
185
|
+
ownership,
|
|
172
186
|
serviceEnvironment: {
|
|
173
187
|
NOWWORK_HOME: nowworkHome,
|
|
174
188
|
NOWWORK_ENV_TAG: environment,
|
|
@@ -178,6 +192,7 @@ export async function probeStorageMigrationRuntime(rawInput, injected = {}) {
|
|
|
178
192
|
{ name: "profile.json", path: profilePath(input.profile, input.currentDaemonHome) },
|
|
179
193
|
{ name: "service-descriptor", path: sourceRecord.descriptorPath },
|
|
180
194
|
{ name: "managed-service-registry.json", path: managedServiceRegistryPath(input.userHome) },
|
|
195
|
+
{ name: "installations.json", path: installationsPath(local.configRoot) },
|
|
181
196
|
],
|
|
182
197
|
}, { assertSourceIdentity });
|
|
183
198
|
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
|
}
|
|
@@ -18,6 +18,8 @@ const DeliveredFrameSchema = z.object({
|
|
|
18
18
|
environment: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,31}$/u),
|
|
19
19
|
workspaceId: z.string().uuid(),
|
|
20
20
|
profile: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,47}$/u),
|
|
21
|
+
ownerEpoch: z.number().int().positive().safe().optional(),
|
|
22
|
+
ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u).optional(),
|
|
21
23
|
sourceStorageFingerprint: z.string().optional(),
|
|
22
24
|
targetStorageFingerprint: z.string().optional(),
|
|
23
25
|
manifestDigest: z.string().optional(),
|
|
@@ -45,6 +47,8 @@ export function storageStatusEventId(frame) {
|
|
|
45
47
|
frame.environment,
|
|
46
48
|
frame.workspaceId,
|
|
47
49
|
frame.profile,
|
|
50
|
+
frame.ownerEpoch ?? null,
|
|
51
|
+
frame.ownershipClaimFingerprint ?? null,
|
|
48
52
|
frame.sourceStorageFingerprint ?? null,
|
|
49
53
|
frame.targetStorageFingerprint ?? null,
|
|
50
54
|
frame.manifestDigest ?? null,
|
|
@@ -35,7 +35,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
|
|
|
35
35
|
if (expected !== undefined && (receipt.migrationId !== expected.migrationId
|
|
36
36
|
|| receipt.environment !== expected.environment
|
|
37
37
|
|| receipt.workspaceId !== expected.workspaceId
|
|
38
|
-
|| receipt.profile !== expected.profile
|
|
38
|
+
|| receipt.profile !== expected.profile
|
|
39
|
+
|| (expected.ownerEpoch !== undefined && receipt.ownerEpoch !== expected.ownerEpoch)
|
|
40
|
+
|| (expected.ownershipClaimFingerprint !== undefined
|
|
41
|
+
&& receipt.ownershipClaimFingerprint !== expected.ownershipClaimFingerprint))) {
|
|
39
42
|
throw new Error("storage migration receipt identity mismatch");
|
|
40
43
|
}
|
|
41
44
|
return Object.freeze(storageMigrationReplayStatuses(receipt).map((status) => Object.freeze({
|
|
@@ -45,6 +48,10 @@ export function storageMigrationStatusReplay(receipt, expected) {
|
|
|
45
48
|
environment: receipt.environment,
|
|
46
49
|
workspaceId: receipt.workspaceId,
|
|
47
50
|
profile: receipt.profile,
|
|
51
|
+
...(receipt.ownerEpoch === undefined ? {} : { ownerEpoch: receipt.ownerEpoch }),
|
|
52
|
+
...(receipt.ownershipClaimFingerprint === undefined ? {} : {
|
|
53
|
+
ownershipClaimFingerprint: receipt.ownershipClaimFingerprint,
|
|
54
|
+
}),
|
|
48
55
|
...(status === "verified" ? { finalManifestDigest: receipt.manifestDigest } : {}),
|
|
49
56
|
...(status === "failed"
|
|
50
57
|
? {
|
|
@@ -98,6 +105,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
|
|
|
98
105
|
environment: input.environment,
|
|
99
106
|
workspaceId: input.workspaceId,
|
|
100
107
|
profile: input.profile,
|
|
108
|
+
...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
|
|
109
|
+
...(input.ownershipClaimFingerprint === undefined ? {} : {
|
|
110
|
+
ownershipClaimFingerprint: input.ownershipClaimFingerprint,
|
|
111
|
+
}),
|
|
101
112
|
errorCode: workerFailure.errorCode,
|
|
102
113
|
errorMessage: workerFailure.errorMessage,
|
|
103
114
|
...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
|
|
@@ -114,6 +125,10 @@ export async function monitorStorageMigrationStatus(input, send, injected = {})
|
|
|
114
125
|
environment: input.environment,
|
|
115
126
|
workspaceId: input.workspaceId,
|
|
116
127
|
profile: input.profile,
|
|
128
|
+
...(input.ownerEpoch === undefined ? {} : { ownerEpoch: input.ownerEpoch }),
|
|
129
|
+
...(input.ownershipClaimFingerprint === undefined ? {} : {
|
|
130
|
+
ownershipClaimFingerprint: input.ownershipClaimFingerprint,
|
|
131
|
+
}),
|
|
117
132
|
errorCode: "worker_heartbeat_stale_recovery_required",
|
|
118
133
|
errorMessage: "storage migration worker heartbeat expired; explicit recovery is required",
|
|
119
134
|
...(receipt?.sourceStopAttempted === false || receipt === null ? { reopenAdmission: true } : {}),
|
|
@@ -367,6 +367,7 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
|
|
|
367
367
|
targetWorkspaceRoot: operation.migration.targetWorkspaceRoot,
|
|
368
368
|
backupsRoot: operation.migration.backupsRoot,
|
|
369
369
|
backupArtifacts: operation.migration.backupArtifacts,
|
|
370
|
+
...(operation.migration.ownership === undefined ? {} : { ownership: operation.migration.ownership }),
|
|
370
371
|
acceptedWarnings: operation.migration.acceptedWarnings,
|
|
371
372
|
onStatus: () => assertHeartbeatHealthy(),
|
|
372
373
|
assertHealthy: assertHeartbeatHealthy,
|
|
@@ -118,6 +118,10 @@ const OperationSchema = z.object({
|
|
|
118
118
|
name: z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,127}$/u),
|
|
119
119
|
path: AbsolutePathSchema,
|
|
120
120
|
}).strict()).max(32),
|
|
121
|
+
ownership: z.object({
|
|
122
|
+
ownerEpoch: z.number().int().positive().safe(),
|
|
123
|
+
ownershipClaimFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
|
|
124
|
+
}).strict().optional(),
|
|
121
125
|
acceptedWarnings: z.array(StorageMigrationWarningCodeSchema)
|
|
122
126
|
.max(StorageMigrationWarningCodeSchema.options.length),
|
|
123
127
|
}).strict(),
|