@nosana/node 1.1.63-rc → 1.1.64-rc
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 +0 -1
- package/dist/.env +2 -4
- package/dist/.env.dev +1 -1
- package/dist/.env.prd +1 -1
- package/dist/package.json +3 -2
- package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
- package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
- package/dist/src/NodeManager/configs/configs.d.ts +0 -3
- package/dist/src/NodeManager/configs/configs.js +2 -5
- package/dist/src/NodeManager/db/index.d.ts +23 -0
- package/dist/src/NodeManager/index.js +9 -4
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.d.ts +2 -3
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.js +8 -12
- package/dist/src/NodeManager/node/Node.d.ts +0 -3
- package/dist/src/NodeManager/node/Node.js +0 -32
- package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
- package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
- package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
- package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
- package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
- package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
- package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
- package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
- package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
- package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
- package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
- package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
- package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +0 -8
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +0 -22
- package/dist/src/NodeManager/provider/Provider.d.ts +30 -14
- package/dist/src/NodeManager/provider/Provider.js +157 -116
- package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
- package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
- package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
- package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
- package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
- package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
- package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
- package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
- package/dist/src/NodeManager/provider/webTerminal.js +98 -0
- package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
- package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
- package/dist/src/cli/commands/prune/action.js +1 -2
- package/dist/src/cli/commands/run/action.js +1 -14
- package/dist/src/cli/commands/run/command.js +1 -2
- package/dist/src/cli/commands/sharedOptions/index.d.ts +0 -1
- package/dist/src/cli/commands/sharedOptions/index.js +0 -1
- package/dist/src/cli/commands/start/action.js +1 -2
- package/dist/src/cli/commands/start/command.js +1 -2
- package/npm-shrinkwrap.json +28 -2
- package/package.json +3 -2
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
- package/dist/src/NodeManager/provider/gpuRuntime.d.ts +0 -13
- package/dist/src/NodeManager/provider/gpuRuntime.js +0 -27
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.d.ts +0 -72
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.js +0 -238
- package/dist/src/cli/commands/sharedOptions/--managed-podman.d.ts +0 -16
- package/dist/src/cli/commands/sharedOptions/--managed-podman.js +0 -25
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { getSDK } from '../../../../../sdk/index.js';
|
|
3
|
+
import { configs } from '../../../../../configs/configs.js';
|
|
4
|
+
import { validateTerminalAuthorizationRequest, } from './terminalAuthorization.js';
|
|
5
|
+
const MAX_TERMINAL_INPUT_BYTES = 1024 * 64;
|
|
6
|
+
/** Pause the PTY while the socket holds this much unsent output. */
|
|
7
|
+
const TERMINAL_OUTPUT_HIGH_WATER_BYTES = 1024 * 1024;
|
|
8
|
+
/** Close the session when a shell that stopped reading has this much queued. */
|
|
9
|
+
const MAX_TERMINAL_PENDING_INPUT_BYTES = 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Validates a wallet-signed terminal grant and bridges an interactive PTY exec
|
|
12
|
+
* session in the job container to the websocket.
|
|
13
|
+
*
|
|
14
|
+
* Wire protocol after the (already validated) handshake frame:
|
|
15
|
+
* - client -> node : JSON text frames
|
|
16
|
+
* { "type": "stdin", "data": "<string>" }
|
|
17
|
+
* { "type": "resize", "cols": <n>, "rows": <n> }
|
|
18
|
+
* - node -> client : binary frames carry raw stdout/stderr bytes
|
|
19
|
+
* JSON text frame { "type": "exit", "code": <n> } on shell exit
|
|
20
|
+
*/
|
|
21
|
+
export async function wssTerminalRoute(ws, provider, nodeAddress, body) {
|
|
22
|
+
const jobId = body?.jobAddress;
|
|
23
|
+
if (!jobId) {
|
|
24
|
+
closeWith(ws, 1008, 'Expected body to contain jobAddress.');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const sdk = getSDK();
|
|
28
|
+
let session;
|
|
29
|
+
try {
|
|
30
|
+
const job = await sdk.jobs.get(jobId);
|
|
31
|
+
if (!job) {
|
|
32
|
+
closeWith(ws, 1008, `Could not find job with id ${jobId}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const authorization = validateTerminalAuthorizationRequest({
|
|
36
|
+
body,
|
|
37
|
+
expectedJobId: jobId,
|
|
38
|
+
expectedNodeId: nodeAddress.toString(),
|
|
39
|
+
expectedSigner: job.project.toString(),
|
|
40
|
+
now: new Date(),
|
|
41
|
+
network: configs().network,
|
|
42
|
+
});
|
|
43
|
+
session = await provider.createJobTerminalSession(jobId, {
|
|
44
|
+
op: authorization.op,
|
|
45
|
+
cols: body.cols,
|
|
46
|
+
rows: body.rows,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
closeWith(ws, 4001, `Unauthorized Request: ${error.message}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
bridgeTerminalSession(ws, session);
|
|
54
|
+
}
|
|
55
|
+
function bridgeTerminalSession(ws, session) {
|
|
56
|
+
let closed = false;
|
|
57
|
+
const cleanup = (code) => {
|
|
58
|
+
if (closed)
|
|
59
|
+
return;
|
|
60
|
+
closed = true;
|
|
61
|
+
session.close();
|
|
62
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
63
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
64
|
+
try {
|
|
65
|
+
ws.close(code ?? 1000);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best effort
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// A shell prints faster than a browser drains; stop reading the PTY while
|
|
73
|
+
// the socket has a backlog and resume once it has been flushed.
|
|
74
|
+
let paused = false;
|
|
75
|
+
const resumeOutput = () => {
|
|
76
|
+
if (paused && ws.bufferedAmount <= TERMINAL_OUTPUT_HIGH_WATER_BYTES) {
|
|
77
|
+
paused = false;
|
|
78
|
+
session.stream.resume();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
session.stream.on('data', (chunk) => {
|
|
82
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
ws.send(chunk, { binary: true }, resumeOutput);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// best effort
|
|
89
|
+
}
|
|
90
|
+
if (!paused && ws.bufferedAmount > TERMINAL_OUTPUT_HIGH_WATER_BYTES) {
|
|
91
|
+
paused = true;
|
|
92
|
+
session.stream.pause();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const writeInput = (buffer) => {
|
|
96
|
+
if (buffer.byteLength > MAX_TERMINAL_INPUT_BYTES)
|
|
97
|
+
return;
|
|
98
|
+
if (!session.stream.write(buffer) &&
|
|
99
|
+
session.stream.writableLength > MAX_TERMINAL_PENDING_INPUT_BYTES) {
|
|
100
|
+
cleanup(1013);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
session.stream.on('end', async () => {
|
|
104
|
+
const code = await session.inspectExitCode();
|
|
105
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
106
|
+
try {
|
|
107
|
+
ws.send(JSON.stringify({ type: 'exit', code }));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// best effort
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
cleanup(1000);
|
|
114
|
+
});
|
|
115
|
+
session.stream.on('error', () => cleanup(1011));
|
|
116
|
+
ws.on('message', (message, isBinary) => {
|
|
117
|
+
if (closed)
|
|
118
|
+
return;
|
|
119
|
+
if (isBinary) {
|
|
120
|
+
writeInput(toBuffer(message));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(toBuffer(message).toString('utf8'));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
switch (parsed.type) {
|
|
131
|
+
case 'stdin':
|
|
132
|
+
if (typeof parsed.data === 'string') {
|
|
133
|
+
writeInput(Buffer.from(parsed.data, 'utf8'));
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
case 'resize':
|
|
137
|
+
if (typeof parsed.cols === 'number' &&
|
|
138
|
+
typeof parsed.rows === 'number') {
|
|
139
|
+
void session.resize({ cols: parsed.cols, rows: parsed.rows });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
ws.on('close', () => cleanup());
|
|
147
|
+
ws.on('error', () => cleanup(1011));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A close reason is capped at 123 bytes on the wire; ws throws past that after
|
|
151
|
+
* already moving to CLOSING, which would leave the socket hanging.
|
|
152
|
+
*/
|
|
153
|
+
function closeWith(ws, code, reason) {
|
|
154
|
+
let text = reason;
|
|
155
|
+
while (Buffer.byteLength(text, 'utf8') > 123)
|
|
156
|
+
text = text.slice(0, -1);
|
|
157
|
+
ws.close(code, text);
|
|
158
|
+
}
|
|
159
|
+
function toBuffer(data) {
|
|
160
|
+
if (Buffer.isBuffer(data))
|
|
161
|
+
return data;
|
|
162
|
+
if (Array.isArray(data))
|
|
163
|
+
return Buffer.concat(data);
|
|
164
|
+
return Buffer.from(data);
|
|
165
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TERMINAL_AUTHORIZATION_AUDIENCE, TERMINAL_AUTHORIZATION_MAX_TTL_MS, TERMINAL_AUTHORIZATION_MESSAGE_HEADER } from '@nosana/ssh';
|
|
2
|
+
export { TERMINAL_AUTHORIZATION_AUDIENCE, TERMINAL_AUTHORIZATION_MAX_TTL_MS, TERMINAL_AUTHORIZATION_MESSAGE_HEADER, };
|
|
3
|
+
export type { ParsedTerminalAuthorizationMessage } from '@nosana/ssh';
|
|
4
|
+
export type TerminalAuthorizationRequest = {
|
|
5
|
+
message?: string;
|
|
6
|
+
signature?: string | number[];
|
|
7
|
+
};
|
|
8
|
+
export declare function validateTerminalAuthorizationRequest({ body, expectedJobId, expectedNodeId, expectedSigner, now, network, }: {
|
|
9
|
+
body: TerminalAuthorizationRequest;
|
|
10
|
+
expectedJobId: string;
|
|
11
|
+
expectedNodeId: string;
|
|
12
|
+
expectedSigner: string;
|
|
13
|
+
now: Date;
|
|
14
|
+
network: string;
|
|
15
|
+
}): {
|
|
16
|
+
expiresAt: Date;
|
|
17
|
+
op?: string;
|
|
18
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PublicKey } from '@solana/web3.js';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
import { isSafeTerminalOperation, parseTerminalAuthorizationMessage, TERMINAL_AUTHORIZATION_AUDIENCE, TERMINAL_AUTHORIZATION_MAX_TTL_MS, TERMINAL_AUTHORIZATION_MESSAGE_HEADER, } from '@nosana/ssh';
|
|
4
|
+
// The message contract now lives in @nosana/ssh, shared with the client; re-exported
|
|
5
|
+
// here so callers and tests keep importing it from this module.
|
|
6
|
+
export { TERMINAL_AUTHORIZATION_AUDIENCE, TERMINAL_AUTHORIZATION_MAX_TTL_MS, TERMINAL_AUTHORIZATION_MESSAGE_HEADER, };
|
|
7
|
+
export function validateTerminalAuthorizationRequest({ body, expectedJobId, expectedNodeId, expectedSigner, now, network, }) {
|
|
8
|
+
if (!body?.message || !body.signature) {
|
|
9
|
+
throw new Error('Expected message and signature');
|
|
10
|
+
}
|
|
11
|
+
const parsed = parseTerminalAuthorizationMessage(body.message);
|
|
12
|
+
if (parsed.job !== expectedJobId) {
|
|
13
|
+
throw new Error('Terminal authorization job mismatch');
|
|
14
|
+
}
|
|
15
|
+
if (parsed.node !== expectedNodeId) {
|
|
16
|
+
throw new Error('Terminal authorization node mismatch');
|
|
17
|
+
}
|
|
18
|
+
if (parsed.network && parsed.network !== network) {
|
|
19
|
+
throw new Error('Terminal authorization network mismatch');
|
|
20
|
+
}
|
|
21
|
+
if (parsed.audience && parsed.audience !== TERMINAL_AUTHORIZATION_AUDIENCE) {
|
|
22
|
+
throw new Error('Terminal authorization audience mismatch');
|
|
23
|
+
}
|
|
24
|
+
if (parsed.op && !isSafeTerminalOperation(parsed.op)) {
|
|
25
|
+
throw new Error('Invalid terminal operation selector');
|
|
26
|
+
}
|
|
27
|
+
const expiresAt = new Date(parsed.expiresAt);
|
|
28
|
+
if (!Number.isFinite(expiresAt.getTime())) {
|
|
29
|
+
throw new Error('Invalid terminal authorization expiry');
|
|
30
|
+
}
|
|
31
|
+
if (expiresAt.getTime() <= now.getTime()) {
|
|
32
|
+
throw new Error('Terminal authorization has expired');
|
|
33
|
+
}
|
|
34
|
+
if (expiresAt.getTime() > now.getTime() + TERMINAL_AUTHORIZATION_MAX_TTL_MS) {
|
|
35
|
+
throw new Error('Terminal authorization expiry is too far in the future');
|
|
36
|
+
}
|
|
37
|
+
const signature = decodeSignature(body.signature);
|
|
38
|
+
const signer = new PublicKey(expectedSigner);
|
|
39
|
+
const verified = nacl.sign.detached.verify(Buffer.from(body.message, 'utf8'), signature, signer.toBytes());
|
|
40
|
+
if (!verified) {
|
|
41
|
+
throw new Error('Invalid terminal authorization signature');
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
expiresAt,
|
|
45
|
+
op: parsed.op,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function decodeSignature(signature) {
|
|
49
|
+
if (Array.isArray(signature)) {
|
|
50
|
+
if (signature.length !== 64 ||
|
|
51
|
+
!signature.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
|
|
52
|
+
throw new Error('Invalid terminal authorization signature');
|
|
53
|
+
}
|
|
54
|
+
return Uint8Array.from(signature);
|
|
55
|
+
}
|
|
56
|
+
const decoded = Buffer.from(signature, 'base64');
|
|
57
|
+
if (decoded.length !== 64 || decoded.toString('base64') !== signature) {
|
|
58
|
+
throw new Error('Invalid terminal authorization signature');
|
|
59
|
+
}
|
|
60
|
+
return decoded;
|
|
61
|
+
}
|
|
@@ -28,7 +28,7 @@ export declare class HostManager {
|
|
|
28
28
|
};
|
|
29
29
|
content: {
|
|
30
30
|
"application/json": {
|
|
31
|
-
status: "
|
|
31
|
+
status: "success" | "failed";
|
|
32
32
|
report?: {
|
|
33
33
|
marketAddress: string;
|
|
34
34
|
marketName?: string | undefined;
|
|
@@ -43,7 +43,7 @@ export declare class HostManager {
|
|
|
43
43
|
} | undefined;
|
|
44
44
|
};
|
|
45
45
|
"multipart/form-data": {
|
|
46
|
-
status: "
|
|
46
|
+
status: "success" | "failed";
|
|
47
47
|
report?: {
|
|
48
48
|
marketAddress: string;
|
|
49
49
|
marketName?: string | undefined;
|
|
@@ -58,7 +58,7 @@ export declare class HostManager {
|
|
|
58
58
|
} | undefined;
|
|
59
59
|
};
|
|
60
60
|
"text/plain": {
|
|
61
|
-
status: "
|
|
61
|
+
status: "success" | "failed";
|
|
62
62
|
report?: {
|
|
63
63
|
marketAddress: string;
|
|
64
64
|
marketName?: string | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Resource } from '@nosana/sdk/dist/types/resources.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a resource is fetched from an auth-gated remote — S3 behind IAM
|
|
4
|
+
* credentials or a Hugging Face repo behind an access token. Such a resource is
|
|
5
|
+
* private to the credentials that fetched it, so its cached volume must be
|
|
6
|
+
* locked to the fetching project rather than shared: a later job must present
|
|
7
|
+
* its own credentials, not reuse a cache it was never entitled to.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isAuthGatedResource(resource: Resource): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a resource is fetched from an auth-gated remote — S3 behind IAM
|
|
3
|
+
* credentials or a Hugging Face repo behind an access token. Such a resource is
|
|
4
|
+
* private to the credentials that fetched it, so its cached volume must be
|
|
5
|
+
* locked to the fetching project rather than shared: a later job must present
|
|
6
|
+
* its own credentials, not reuse a cache it was never entitled to.
|
|
7
|
+
*/
|
|
8
|
+
export function isAuthGatedResource(resource) {
|
|
9
|
+
return ((resource.type === 'S3' && !!resource.IAM) ||
|
|
10
|
+
(resource.type === 'HF' && !!resource.accessToken));
|
|
11
|
+
}
|
|
@@ -18,5 +18,18 @@ export declare class ImageManager {
|
|
|
18
18
|
pullMarketRequiredImages(required_images: RequiredImage[]): Promise<void>;
|
|
19
19
|
pruneImages(): Promise<void>;
|
|
20
20
|
resyncImagesDB(): Promise<void>;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Whether a job for `project` must re-authenticate before it may use this
|
|
23
|
+
* image, rather than reuse the cached copy. True only for a private image the
|
|
24
|
+
* node last pulled for a *different* project: the daemon holds one copy per
|
|
25
|
+
* image reference, so the caller forces a pull, which re-verifies this
|
|
26
|
+
* project's credentials against the registry (reusing cached layers) and
|
|
27
|
+
* denies a job that cannot authenticate.
|
|
28
|
+
*
|
|
29
|
+
* The project that already owns the cached image, a public image, and a
|
|
30
|
+
* market-required image (shared across the market by design) all reuse the
|
|
31
|
+
* cache with no registry round-trip.
|
|
32
|
+
*/
|
|
33
|
+
requiresReauth(image: string, project?: string): boolean;
|
|
34
|
+
setImage(image: string, isPrivate?: boolean, project?: string): Promise<void>;
|
|
22
35
|
}
|
|
@@ -45,7 +45,7 @@ export class ImageManager {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
async resyncImagesDB() {
|
|
48
|
-
for (const [image, { lastUsed, required
|
|
48
|
+
for (const [image, { lastUsed, required }] of Object.entries(this.repository.getImagesResources())) {
|
|
49
49
|
if (!(await this.containerOrchestration.hasImage(image))) {
|
|
50
50
|
this.repository.deleteImageResource(image);
|
|
51
51
|
continue;
|
|
@@ -54,19 +54,45 @@ export class ImageManager {
|
|
|
54
54
|
this.market_required_images.includes(image)) {
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
|
+
// A private image is kept cached like any other and ages out on the same
|
|
58
|
+
// unused rule: cross-project reuse is gated at pull time by `project`
|
|
59
|
+
// (see requiresReauth), so it no longer has to be dropped on sight.
|
|
57
60
|
const hoursSinceLastUsed = hoursSinceDate(new Date(lastUsed));
|
|
58
|
-
if (hoursSinceLastUsed > 24
|
|
61
|
+
if (hoursSinceLastUsed > 24) {
|
|
59
62
|
await this.containerOrchestration.deleteImage(image);
|
|
60
63
|
this.repository.deleteImageResource(image);
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Whether a job for `project` must re-authenticate before it may use this
|
|
69
|
+
* image, rather than reuse the cached copy. True only for a private image the
|
|
70
|
+
* node last pulled for a *different* project: the daemon holds one copy per
|
|
71
|
+
* image reference, so the caller forces a pull, which re-verifies this
|
|
72
|
+
* project's credentials against the registry (reusing cached layers) and
|
|
73
|
+
* denies a job that cannot authenticate.
|
|
74
|
+
*
|
|
75
|
+
* The project that already owns the cached image, a public image, and a
|
|
76
|
+
* market-required image (shared across the market by design) all reuse the
|
|
77
|
+
* cache with no registry round-trip.
|
|
78
|
+
*/
|
|
79
|
+
requiresReauth(image, project) {
|
|
80
|
+
if (this.market_required_images.includes(image))
|
|
81
|
+
return false;
|
|
82
|
+
const entry = this.repository.getImageResource(image);
|
|
83
|
+
return !!entry?.isPrivate && entry.project !== project;
|
|
84
|
+
}
|
|
85
|
+
async setImage(image, isPrivate = false, project) {
|
|
65
86
|
const imageObj = this.repository.getImageResource(image);
|
|
66
87
|
this.repository.updateImageResource(image, {
|
|
67
88
|
lastUsed: new Date(),
|
|
68
89
|
usage: imageObj?.usage + 1 || 1,
|
|
69
|
-
|
|
90
|
+
// Never downgrade a known-private image to public — a later reuse by its
|
|
91
|
+
// owner arrives without credentials — and keep the owning project when
|
|
92
|
+
// this pull did not re-establish one. A different project claims the image
|
|
93
|
+
// only by passing its own project after a successful credentialed pull.
|
|
94
|
+
isPrivate: isPrivate || !!imageObj?.isPrivate,
|
|
95
|
+
project: project ?? imageObj?.project,
|
|
70
96
|
});
|
|
71
97
|
}
|
|
72
98
|
}
|
|
@@ -45,7 +45,7 @@ export declare class ResourceManager {
|
|
|
45
45
|
resyncResourcesDB(): Promise<void>;
|
|
46
46
|
fetchMarketRequiredResources(market: Market | string): Promise<void>;
|
|
47
47
|
prune(): Promise<void>;
|
|
48
|
-
getResourceVolumes(resources: Resource[], controller: AbortController, job?: JobContext): Promise<{
|
|
48
|
+
getResourceVolumes(resources: Resource[], controller: AbortController, job?: JobContext, project?: string): Promise<{
|
|
49
49
|
dest: string;
|
|
50
50
|
name: string;
|
|
51
51
|
readonly?: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HostManager } from '../market/hostManager.js';
|
|
2
2
|
import { createResourceName } from './helpers/createResourceName.js';
|
|
3
|
+
import { isAuthGatedResource } from './helpers/isAuthGatedResource.js';
|
|
3
4
|
import { ImageManager } from './image/imageManager.js';
|
|
4
5
|
import { VolumeManager } from './volume/volumeManager.js';
|
|
5
6
|
/**
|
|
@@ -71,11 +72,26 @@ export class ResourceManager {
|
|
|
71
72
|
await this.images.pruneImages();
|
|
72
73
|
await this.volumes.pruneVolumes();
|
|
73
74
|
}
|
|
74
|
-
async getResourceVolumes(resources, controller, job) {
|
|
75
|
+
async getResourceVolumes(resources, controller, job, project) {
|
|
75
76
|
const volumes = [];
|
|
76
77
|
for (const resource of resources) {
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
const allowWrite = resource.type === 'S3' && !!resource.allowWrite;
|
|
79
|
+
// A writable mount can leave the job's bytes behind, and an auth-gated
|
|
80
|
+
// remote is private to the credentials that fetched it; either way the
|
|
81
|
+
// volume must be locked to this project rather than shared, so a different
|
|
82
|
+
// project pulls its own clean copy with its own credentials.
|
|
83
|
+
const lockToProject = allowWrite || isAuthGatedResource(resource);
|
|
84
|
+
// Locks the resource to this project (claiming the shared copy if one
|
|
85
|
+
// exists) so its volume is never reused across projects. Unlocked
|
|
86
|
+
// resources resolve to the shared key, untouched — unless this project
|
|
87
|
+
// already holds a claim, which is reused for every later job.
|
|
88
|
+
const resourceKey = this.volumes.resolveResourceKey(resource, project, lockToProject);
|
|
89
|
+
// A project-scoped key must keep carrying its project so a read-only
|
|
90
|
+
// reuse does not clear the claim; the shared key carries none. The key
|
|
91
|
+
// itself is the source of truth here, not this job's lock state.
|
|
92
|
+
const claimedForProject = resourceKey !== createResourceName(resource);
|
|
93
|
+
await this.volumes.createRemoteVolume(resource, controller, job, resourceKey, claimedForProject ? project : undefined);
|
|
94
|
+
if ((await this.volumes.hasVolume(resource, resourceKey)) === false) {
|
|
79
95
|
const error = new Error(`Missing required resource ${createResourceName(resource)}.`);
|
|
80
96
|
throw error;
|
|
81
97
|
}
|
|
@@ -85,8 +101,8 @@ export class ResourceManager {
|
|
|
85
101
|
? '/root/.ollama/models'
|
|
86
102
|
: resource.target
|
|
87
103
|
: resource.target,
|
|
88
|
-
name: await this.volumes.getVolume(resource),
|
|
89
|
-
readonly:
|
|
104
|
+
name: await this.volumes.getVolume(resource, resourceKey),
|
|
105
|
+
readonly: !allowWrite,
|
|
90
106
|
});
|
|
91
107
|
}
|
|
92
108
|
return volumes;
|
|
@@ -52,11 +52,38 @@ export declare class VolumeManager {
|
|
|
52
52
|
private progressBarReporter;
|
|
53
53
|
constructor(containerOrchestration: ContainerOrchestrationInterface, repository: NodeRepository);
|
|
54
54
|
pullMarketRequiredVolumes(remoteResources: RequiredResource[]): Promise<void>;
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The DB key a job's resource must use, locking a resource to one project
|
|
57
|
+
* when it must not be shared across projects.
|
|
58
|
+
*
|
|
59
|
+
* Two things force a lock. A writable (`allowWrite`) mount can leave the job's
|
|
60
|
+
* own bytes in the cached volume, so a later job reusing it could be handed
|
|
61
|
+
* code an earlier project planted. An auth-gated resource is private to the
|
|
62
|
+
* credentials that fetched it, so a later job reusing the cache would read
|
|
63
|
+
* private data without presenting its own credentials. Either way the first
|
|
64
|
+
* such use claims the resource for its project — the volume is tagged and
|
|
65
|
+
* moved to a project-scoped key, which frees the shared key for the clean copy
|
|
66
|
+
* the next project downloads with its own credentials.
|
|
67
|
+
*
|
|
68
|
+
* Claiming renames the DB entry only; the Docker volume and its contents stay
|
|
69
|
+
* in place, so the claiming project inherits the already-downloaded data with
|
|
70
|
+
* no re-download. The next use of the shared key pays for a fresh pull.
|
|
71
|
+
*
|
|
72
|
+
* Once a project holds a claim, every later job of that project reuses it —
|
|
73
|
+
* read-only ones included, even public ones that would not have locked on
|
|
74
|
+
* their own. The claimed copy holds only what this project's own jobs put
|
|
75
|
+
* there, so reusing it leaks nothing across projects and spares the project a
|
|
76
|
+
* second download of a resource it already has.
|
|
77
|
+
*
|
|
78
|
+
* A resource with no claim for this project and nothing forcing one (a public,
|
|
79
|
+
* read-only mount) and market preloads stay on the shared key, reused freely.
|
|
80
|
+
*/
|
|
81
|
+
resolveResourceKey(resource: Resource, project?: string, lockToProject?: boolean): string;
|
|
82
|
+
createRemoteVolume(resource: RequiredResource, controller: AbortController, job?: JobContext, resourceKey?: string, project?: string): Promise<string>;
|
|
56
83
|
private runResourceManagerContainer;
|
|
57
|
-
setVolume(bucket: string, volume: string): Promise<void>;
|
|
58
|
-
hasVolume(resource: Resource): Promise<boolean>;
|
|
59
|
-
getVolume(resource: RequiredResource | Resource): Promise<string>;
|
|
84
|
+
setVolume(bucket: string, volume: string, project?: string): Promise<void>;
|
|
85
|
+
hasVolume(resource: Resource, resourceKey?: string): Promise<boolean>;
|
|
86
|
+
getVolume(resource: RequiredResource | Resource, resourceKey?: string): Promise<string>;
|
|
60
87
|
pruneVolumes(): Promise<void>;
|
|
61
88
|
resyncResourcesDB(): Promise<void>;
|
|
62
89
|
}
|
|
@@ -115,8 +115,63 @@ export class VolumeManager {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The DB key a job's resource must use, locking a resource to one project
|
|
120
|
+
* when it must not be shared across projects.
|
|
121
|
+
*
|
|
122
|
+
* Two things force a lock. A writable (`allowWrite`) mount can leave the job's
|
|
123
|
+
* own bytes in the cached volume, so a later job reusing it could be handed
|
|
124
|
+
* code an earlier project planted. An auth-gated resource is private to the
|
|
125
|
+
* credentials that fetched it, so a later job reusing the cache would read
|
|
126
|
+
* private data without presenting its own credentials. Either way the first
|
|
127
|
+
* such use claims the resource for its project — the volume is tagged and
|
|
128
|
+
* moved to a project-scoped key, which frees the shared key for the clean copy
|
|
129
|
+
* the next project downloads with its own credentials.
|
|
130
|
+
*
|
|
131
|
+
* Claiming renames the DB entry only; the Docker volume and its contents stay
|
|
132
|
+
* in place, so the claiming project inherits the already-downloaded data with
|
|
133
|
+
* no re-download. The next use of the shared key pays for a fresh pull.
|
|
134
|
+
*
|
|
135
|
+
* Once a project holds a claim, every later job of that project reuses it —
|
|
136
|
+
* read-only ones included, even public ones that would not have locked on
|
|
137
|
+
* their own. The claimed copy holds only what this project's own jobs put
|
|
138
|
+
* there, so reusing it leaks nothing across projects and spares the project a
|
|
139
|
+
* second download of a resource it already has.
|
|
140
|
+
*
|
|
141
|
+
* A resource with no claim for this project and nothing forcing one (a public,
|
|
142
|
+
* read-only mount) and market preloads stay on the shared key, reused freely.
|
|
143
|
+
*/
|
|
144
|
+
resolveResourceKey(resource, project, lockToProject = false) {
|
|
145
|
+
const sharedKey = createResourceName(resource);
|
|
146
|
+
if (!project) {
|
|
147
|
+
return sharedKey;
|
|
148
|
+
}
|
|
149
|
+
const claimedKey = `${sharedKey}::${project}`;
|
|
150
|
+
// Already claimed by this project on an earlier job — reuse it, writes and
|
|
151
|
+
// all, whatever this job's own lock state. This is what lets a later
|
|
152
|
+
// read-only job read back a copy an earlier writable job of the same
|
|
153
|
+
// project produced, rather than re-downloading it.
|
|
154
|
+
if (this.repository.getVolumeResource(claimedKey)) {
|
|
155
|
+
return claimedKey;
|
|
156
|
+
}
|
|
157
|
+
// No claim yet, and nothing forces one: an unlocked (public, read-only)
|
|
158
|
+
// resource stays on the shared key, reused freely across projects.
|
|
159
|
+
if (!lockToProject) {
|
|
160
|
+
return sharedKey;
|
|
161
|
+
}
|
|
162
|
+
// First locked use of this resource by this project. An unclaimed shared
|
|
163
|
+
// volume already holds it: claim it in place rather than re-downloading.
|
|
164
|
+
// Moving it to the project key frees the shared key for the next project's
|
|
165
|
+
// clean copy.
|
|
166
|
+
const shared = this.repository.getVolumeResource(sharedKey);
|
|
167
|
+
if (shared && !shared.project) {
|
|
168
|
+
this.repository.updateVolumeResource(claimedKey, { ...shared, project });
|
|
169
|
+
this.repository.deleteVolumeResource(sharedKey);
|
|
170
|
+
}
|
|
171
|
+
return claimedKey;
|
|
172
|
+
}
|
|
173
|
+
async createRemoteVolume(resource, controller, job, resourceKey, project) {
|
|
174
|
+
const resourceName = resourceKey ?? createResourceName(resource);
|
|
120
175
|
// Checked before anything is allocated. Left to the download, an empty repo
|
|
121
176
|
// becomes a request for `/api/models//tree/main` and comes back as a bare
|
|
122
177
|
// 404 with nothing in it naming the resource at fault.
|
|
@@ -141,6 +196,7 @@ export class VolumeManager {
|
|
|
141
196
|
lastUsed: new Date(),
|
|
142
197
|
usage: 0,
|
|
143
198
|
pending: true,
|
|
199
|
+
project,
|
|
144
200
|
});
|
|
145
201
|
}
|
|
146
202
|
switch (resource.type) {
|
|
@@ -161,7 +217,7 @@ export class VolumeManager {
|
|
|
161
217
|
await this.runResourceManagerContainer(volumeName, resourceName, args, controller, sync, job);
|
|
162
218
|
}
|
|
163
219
|
}
|
|
164
|
-
this.setVolume(resourceName, volumeName);
|
|
220
|
+
this.setVolume(resourceName, volumeName, project);
|
|
165
221
|
}
|
|
166
222
|
catch (err) {
|
|
167
223
|
throw new Error(err.message);
|
|
@@ -172,7 +228,7 @@ export class VolumeManager {
|
|
|
172
228
|
const { repo, revision, files: hfFiles, accessToken, } = resource;
|
|
173
229
|
const args = createHFArgs(volumeName, { repo, revision, files: hfFiles }, accessToken);
|
|
174
230
|
await this.runResourceManagerContainer(volumeName, resourceName, args, controller, sync, job);
|
|
175
|
-
this.setVolume(resourceName, volumeName);
|
|
231
|
+
this.setVolume(resourceName, volumeName, project);
|
|
176
232
|
}
|
|
177
233
|
catch (err) {
|
|
178
234
|
throw new Error(err.message);
|
|
@@ -183,7 +239,7 @@ export class VolumeManager {
|
|
|
183
239
|
const { model } = resource;
|
|
184
240
|
const args = createOllamaArgs(volumeName, model);
|
|
185
241
|
await this.runResourceManagerContainer(volumeName, resourceName, args, controller, sync, job);
|
|
186
|
-
this.setVolume(resourceName, volumeName);
|
|
242
|
+
this.setVolume(resourceName, volumeName, project);
|
|
187
243
|
}
|
|
188
244
|
catch (err) {
|
|
189
245
|
throw new Error(err.message);
|
|
@@ -284,7 +340,7 @@ export class VolumeManager {
|
|
|
284
340
|
this.progressBarReporter.stop(`${syncing ? 'Synced' : 'Downloaded'} resource ${name} completed`);
|
|
285
341
|
await this.containerOrchestration.stopAndDeleteContainer(container.id);
|
|
286
342
|
}
|
|
287
|
-
async setVolume(bucket, volume) {
|
|
343
|
+
async setVolume(bucket, volume, project) {
|
|
288
344
|
const volumeObj = this.repository.getVolumeResource(bucket);
|
|
289
345
|
this.repository.updateVolumeResource(bucket, {
|
|
290
346
|
volume,
|
|
@@ -293,22 +349,23 @@ export class VolumeManager {
|
|
|
293
349
|
usage: volumeObj?.usage + 1 || 1,
|
|
294
350
|
// Only a completed sync reaches here, which is what pending means.
|
|
295
351
|
pending: false,
|
|
352
|
+
project,
|
|
296
353
|
});
|
|
297
354
|
}
|
|
298
|
-
async hasVolume(resource) {
|
|
299
|
-
const
|
|
355
|
+
async hasVolume(resource, resourceKey) {
|
|
356
|
+
const key = resourceKey ?? createResourceName(resource);
|
|
357
|
+
const volume = this.repository.getVolumeResource(key)?.volume;
|
|
300
358
|
if (!volume) {
|
|
301
359
|
return false;
|
|
302
360
|
}
|
|
303
361
|
const dockerHasVolume = await this.containerOrchestration.hasVolume(volume);
|
|
304
362
|
if (!dockerHasVolume) {
|
|
305
|
-
this.repository.deleteVolumeResource(
|
|
363
|
+
this.repository.deleteVolumeResource(key);
|
|
306
364
|
}
|
|
307
365
|
return dockerHasVolume;
|
|
308
366
|
}
|
|
309
|
-
async getVolume(resource) {
|
|
310
|
-
return this.repository.getVolumeResource(createResourceName(resource))
|
|
311
|
-
?.volume;
|
|
367
|
+
async getVolume(resource, resourceKey) {
|
|
368
|
+
return this.repository.getVolumeResource(resourceKey ?? createResourceName(resource))?.volume;
|
|
312
369
|
}
|
|
313
370
|
async pruneVolumes() {
|
|
314
371
|
const cachedVolumes = await this.containerOrchestration.listVolumes();
|
|
@@ -47,13 +47,5 @@ export declare class TaskManagerRegistry {
|
|
|
47
47
|
/** Jobs with a task manager, which is to say the jobs this node is running. */
|
|
48
48
|
size(): number;
|
|
49
49
|
has(jobId: string): boolean;
|
|
50
|
-
/**
|
|
51
|
-
* Restart every operation that is running, in every job: the runtime under
|
|
52
|
-
* them is being replaced. Operations that finished stay finished and ones
|
|
53
|
-
* that have not started pick up the new runtime on their own; only what is
|
|
54
|
-
* running is aborted now and relaunched, which the provider holds until the
|
|
55
|
-
* runtime answers again.
|
|
56
|
-
*/
|
|
57
|
-
restartRunningOperations(): void;
|
|
58
50
|
stop(): Promise<void>;
|
|
59
51
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { OperationProgressStatuses } from './TaskManager.js';
|
|
2
1
|
/**
|
|
3
2
|
* TaskManagerRegistry
|
|
4
3
|
*
|
|
@@ -62,27 +61,6 @@ export class TaskManagerRegistry {
|
|
|
62
61
|
has(jobId) {
|
|
63
62
|
return this.registry.has(jobId);
|
|
64
63
|
}
|
|
65
|
-
/**
|
|
66
|
-
* Restart every operation that is running, in every job: the runtime under
|
|
67
|
-
* them is being replaced. Operations that finished stay finished and ones
|
|
68
|
-
* that have not started pick up the new runtime on their own; only what is
|
|
69
|
-
* running is aborted now and relaunched, which the provider holds until the
|
|
70
|
-
* runtime answers again.
|
|
71
|
-
*/
|
|
72
|
-
restartRunningOperations() {
|
|
73
|
-
for (const manager of this.registry.values()) {
|
|
74
|
-
const group = manager.getCurrentGroup();
|
|
75
|
-
if (!group)
|
|
76
|
-
continue;
|
|
77
|
-
for (const [opId, status] of Object.entries(manager.getCurrentGroupStatus())) {
|
|
78
|
-
if (status !== OperationProgressStatuses.RUNNING)
|
|
79
|
-
continue;
|
|
80
|
-
manager.restartTaskManagerOperation(group, opId).catch((error) => {
|
|
81
|
-
console.warn(`Could not restart operation ${opId} on the new runtime:`, error);
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
64
|
async stop() {
|
|
87
65
|
const stopPromises = [];
|
|
88
66
|
for (const [jobId, manager] of this.registry.entries()) {
|