@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.
Files changed (82) hide show
  1. package/README.md +0 -1
  2. package/dist/.env +2 -4
  3. package/dist/.env.dev +1 -1
  4. package/dist/.env.prd +1 -1
  5. package/dist/package.json +3 -2
  6. package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
  7. package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
  8. package/dist/src/NodeManager/configs/configs.d.ts +0 -3
  9. package/dist/src/NodeManager/configs/configs.js +2 -5
  10. package/dist/src/NodeManager/db/index.d.ts +23 -0
  11. package/dist/src/NodeManager/index.js +9 -4
  12. package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.d.ts +2 -3
  13. package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.js +8 -12
  14. package/dist/src/NodeManager/node/Node.d.ts +0 -3
  15. package/dist/src/NodeManager/node/Node.js +0 -32
  16. package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
  17. package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
  18. package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
  19. package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
  20. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
  21. package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
  22. package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
  23. package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
  24. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
  25. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
  26. package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
  27. package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
  28. package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
  29. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
  30. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
  31. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
  32. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
  33. package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
  34. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
  35. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
  36. package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
  37. package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
  38. package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
  39. package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
  40. package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
  41. package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
  42. package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +0 -8
  43. package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +0 -22
  44. package/dist/src/NodeManager/provider/Provider.d.ts +30 -14
  45. package/dist/src/NodeManager/provider/Provider.js +157 -116
  46. package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
  47. package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
  48. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
  49. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
  50. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
  51. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
  52. package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
  53. package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
  54. package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
  55. package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
  56. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
  57. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
  58. package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
  59. package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
  60. package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
  61. package/dist/src/NodeManager/provider/webTerminal.js +98 -0
  62. package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
  63. package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
  64. package/dist/src/cli/commands/prune/action.js +1 -2
  65. package/dist/src/cli/commands/run/action.js +1 -14
  66. package/dist/src/cli/commands/run/command.js +1 -2
  67. package/dist/src/cli/commands/sharedOptions/index.d.ts +0 -1
  68. package/dist/src/cli/commands/sharedOptions/index.js +0 -1
  69. package/dist/src/cli/commands/start/action.js +1 -2
  70. package/dist/src/cli/commands/start/command.js +1 -2
  71. package/npm-shrinkwrap.json +28 -2
  72. package/package.json +3 -2
  73. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
  74. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
  75. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
  76. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
  77. package/dist/src/NodeManager/provider/gpuRuntime.d.ts +0 -13
  78. package/dist/src/NodeManager/provider/gpuRuntime.js +0 -27
  79. package/dist/src/NodeManager/provider/podmanManager/PodmanManager.d.ts +0 -72
  80. package/dist/src/NodeManager/provider/podmanManager/PodmanManager.js +0 -238
  81. package/dist/src/cli/commands/sharedOptions/--managed-podman.d.ts +0 -16
  82. 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: "failed" | "success";
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: "failed" | "success";
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: "failed" | "success";
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
- setImage(image: string, isPrivate?: boolean): Promise<void>;
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, isPrivate }] of Object.entries(this.repository.getImagesResources())) {
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 || isPrivate) {
61
+ if (hoursSinceLastUsed > 24) {
59
62
  await this.containerOrchestration.deleteImage(image);
60
63
  this.repository.deleteImageResource(image);
61
64
  }
62
65
  }
63
66
  }
64
- async setImage(image, isPrivate = false) {
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
- isPrivate,
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
- await this.volumes.createRemoteVolume(resource, controller, job);
78
- if ((await this.volumes.hasVolume(resource)) === false) {
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: resource.type === 'S3' && resource.allowWrite ? false : true,
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
- createRemoteVolume(resource: RequiredResource, controller: AbortController, job?: JobContext): Promise<string>;
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
- async createRemoteVolume(resource, controller, job) {
119
- const resourceName = createResourceName(resource);
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 volume = this.repository.getVolumeResource(createResourceName(resource))?.volume;
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(createResourceName(resource));
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()) {