@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
@@ -4,30 +4,46 @@ import EventEmitter from 'events';
4
4
  import { NodeRepository } from '../repository/NodeRepository.js';
5
5
  import { ResourceManager } from '../node/resource/resourceManager.js';
6
6
  import { ContainerOrchestrationInterface } from './containerOrchestration/interface.js';
7
- import { type GpuRuntime } from './gpuRuntime.js';
7
+ import { type TerminalSession } from './webTerminal.js';
8
8
  export declare class Provider {
9
9
  containerOrchestration: ContainerOrchestrationInterface;
10
10
  private repository;
11
11
  private resourceManager;
12
12
  private readonly frpcImage;
13
+ private readonly sshAccess;
13
14
  private proxyStartupAbortController;
14
- /**
15
- * Where the node's own API proxy runs: the job runtime, unless the node
16
- * manages podman, in which case it runs in docker beside the node so that
17
- * replacing podman does not take the API down with it.
18
- */
19
- apiOrchestration: ContainerOrchestrationInterface;
20
- /**
21
- * The node's podman manager, when the node manages podman: operation starts
22
- * wait out a recreate, and one whose container failed to start on the GPU
23
- * runtime has podman recreated and is run again rather than failed.
24
- */
25
- gpuRuntime?: GpuRuntime;
26
15
  constructor(containerOrchestration: ContainerOrchestrationInterface, repository: NodeRepository, resourceManager: ResourceManager);
27
16
  hasTrustedExecutionEnvCapability(): Promise<boolean>;
28
17
  stopReverseProxyApi(address: string): Promise<boolean>;
29
18
  setUpReverseProxyApi(address: string, port: string): Promise<boolean>;
30
- taskManagerContainerRunOperation(flow: Flow, op: Operation<'container/run'>, controller: AbortController, emitter: EventEmitter, retried?: boolean): Promise<void>;
19
+ generateApiFrpcProxies(address: string, port: string): any[];
20
+ /**
21
+ * Opens an interactive PTY exec session into a running job container for the
22
+ * wallet-authorized web terminal. The authorization check is performed by the
23
+ * caller (the terminal websocket route); this only enforces that the feature
24
+ * is enabled and that we can reach the runtime over a socket.
25
+ */
26
+ createJobTerminalSession(jobId: string, options?: {
27
+ op?: string;
28
+ cols?: number;
29
+ rows?: number;
30
+ }): Promise<TerminalSession>;
31
+ authorizeSshKeyForJob(jobId: string, publicKey: string, expiresAt?: string): Promise<void>;
32
+ listSshKeysForJob(jobId: string): Promise<import("../db/index.js").SshKeyGrant[]>;
33
+ revokeSshKeyForJob(jobId: string, publicKey: string): Promise<void>;
34
+ /**
35
+ * SSH requires the runtime to be reached over a socket the sidecar can mount
36
+ * to exec into the workload. Every SSH entry point, including the web
37
+ * terminal, checks this and nothing else.
38
+ */
39
+ private get sshEnabled();
40
+ private assertSshEnabled;
41
+ /** The SSH sidecars of a job, one per operation, created ones included. */
42
+ private getSshSidecars;
43
+ private hostSocketPath?;
44
+ /** The runtime socket as a host path, which is what a bind source must be. */
45
+ private resolveHostSocketPath;
46
+ taskManagerContainerRunOperation(flow: Flow, op: Operation<'container/run'>, controller: AbortController, emitter: EventEmitter): Promise<void>;
31
47
  generateFrpcContainerConfig(name: string, networks: {
32
48
  [p: string]: {};
33
49
  }, flow: Flow, proxies: any[], isLoadBalanced: boolean, deploymentHash: string | undefined): {
@@ -8,7 +8,10 @@ import { promiseTimeoutWrapper } from '../utils/timeoutPromiseWrapper.js';
8
8
  import { liveAbortSignal } from '../utils/liveAbortSignal.js';
9
9
  import { generateProxies, generateUrlSecretObject, } from '../utils/expose-util.js';
10
10
  import { ContainerStateManager } from './ContainerStateManager.js';
11
- import { isStaleGpuRuntimeError } from './gpuRuntime.js';
11
+ import { sshSidecarFilters, withSshSidecar } from './sshSidecar.js';
12
+ import { JOB_LABEL, OP_INDEX_LABEL, OP_LABEL, } from './containerOrchestration/labels.js';
13
+ import { createJobTerminalExec, } from './webTerminal.js';
14
+ import { SshAccessError, SshAccessManager } from './SshAccessManager.js';
12
15
  export class Provider {
13
16
  constructor(containerOrchestration, repository, resourceManager) {
14
17
  this.containerOrchestration = containerOrchestration;
@@ -16,7 +19,7 @@ export class Provider {
16
19
  this.resourceManager = resourceManager;
17
20
  this.frpcImage = configs().frp.containerImage;
18
21
  this.proxyStartupAbortController = undefined;
19
- this.apiOrchestration = containerOrchestration;
22
+ this.sshAccess = new SshAccessManager(repository, (jobId) => this.getSshSidecars(jobId));
20
23
  applyLoggingProxyToClass(this);
21
24
  }
22
25
  async hasTrustedExecutionEnvCapability() {
@@ -30,13 +33,13 @@ export class Provider {
30
33
  }
31
34
  try {
32
35
  // Check if the frpc container exists and stop/delete it
33
- const doesFrpcExist = await this.apiOrchestration.doesContainerExist(frpc_name);
36
+ const doesFrpcExist = await this.containerOrchestration.doesContainerExist(frpc_name);
34
37
  if (doesFrpcExist) {
35
- await this.apiOrchestration.stopAndDeleteContainer(frpc_name);
38
+ await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
36
39
  }
37
40
  // check if network then delete
38
- if (await this.apiOrchestration.hasNetwork(networkName)) {
39
- await this.apiOrchestration.deleteNetwork(networkName);
41
+ if (await this.containerOrchestration.hasNetwork(networkName)) {
42
+ await this.containerOrchestration.deleteNetwork(networkName);
40
43
  }
41
44
  }
42
45
  catch (error) {
@@ -47,24 +50,23 @@ export class Provider {
47
50
  // set up reverse proxy api for api handler
48
51
  async setUpReverseProxyApi(address, port) {
49
52
  try {
50
- const nodeName = configs().frp.nodeInternalHostName;
51
53
  const networkName = `api-${address}`;
52
54
  const frpc_name = `frpc-api-${address}`;
53
- await this.apiOrchestration.createNetwork(networkName);
55
+ await this.containerOrchestration.createNetwork(networkName);
54
56
  const networks = {};
55
57
  networks[networkName] = {};
56
58
  const startup = new AbortController();
57
59
  this.proxyStartupAbortController = startup;
58
- await this.apiOrchestration.pullImage(this.frpcImage, undefined, startup);
60
+ await this.containerOrchestration.pullImage(this.frpcImage, undefined, startup);
59
61
  this.resourceManager.images.setImage(this.frpcImage);
60
- const doesFrpcExist = await this.apiOrchestration.doesContainerExist(frpc_name);
62
+ const doesFrpcExist = await this.containerOrchestration.doesContainerExist(frpc_name);
61
63
  // Stopped while the image was being pulled, so the proxy is not wanted:
62
64
  // creating it now would put back what the caller has taken down.
63
65
  if (startup.signal.aborted) {
64
66
  throw new Error('API proxy setup was stopped');
65
67
  }
66
68
  if (!doesFrpcExist) {
67
- await this.apiOrchestration.runFlowContainer(this.frpcImage, {
69
+ await this.containerOrchestration.runFlowContainer(this.frpcImage, {
68
70
  name: 'frpc-api-' + address,
69
71
  cmd: ['-c', '/etc/frp/frpc.toml'],
70
72
  networks,
@@ -73,14 +75,7 @@ export class Provider {
73
75
  env: {
74
76
  FRP_SERVER_ADDR: configs().frp.serverAddr,
75
77
  FRP_SERVER_PORT: configs().frp.serverPort.toString(),
76
- FRP_PROXIES: JSON.stringify([
77
- {
78
- name: 'API-' + address,
79
- localIp: nodeName,
80
- localPorts: port.toString(),
81
- customDomain: address + '.' + configs().frp.serverAddr,
82
- },
83
- ]),
78
+ FRP_PROXIES: JSON.stringify(this.generateApiFrpcProxies(address, port)),
84
79
  },
85
80
  volumes: [
86
81
  {
@@ -91,10 +86,10 @@ export class Provider {
91
86
  });
92
87
  }
93
88
  else {
94
- const hasFrpcExited = await this.apiOrchestration.isContainerExited(frpc_name);
89
+ const hasFrpcExited = await this.containerOrchestration.isContainerExited(frpc_name);
95
90
  if (hasFrpcExited) {
96
- await this.apiOrchestration.stopAndDeleteContainer(frpc_name);
97
- await this.apiOrchestration.runFlowContainer(this.frpcImage, {
91
+ await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
92
+ await this.containerOrchestration.runFlowContainer(this.frpcImage, {
98
93
  name: 'frpc-api-' + address,
99
94
  cmd: ['-c', '/etc/frp/frpc.toml'],
100
95
  networks,
@@ -103,14 +98,7 @@ export class Provider {
103
98
  env: {
104
99
  FRP_SERVER_ADDR: configs().frp.serverAddr,
105
100
  FRP_SERVER_PORT: configs().frp.serverPort.toString(),
106
- FRP_PROXIES: JSON.stringify([
107
- {
108
- name: 'API-' + address,
109
- localIp: nodeName,
110
- localPorts: port.toString(),
111
- customDomain: address + '.' + configs().frp.serverAddr,
112
- },
113
- ]),
101
+ FRP_PROXIES: JSON.stringify(this.generateApiFrpcProxies(address, port)),
114
102
  },
115
103
  volumes: [
116
104
  {
@@ -128,7 +116,64 @@ export class Provider {
128
116
  this.proxyStartupAbortController = undefined;
129
117
  return true;
130
118
  }
131
- async taskManagerContainerRunOperation(flow, op, controller, emitter, retried = false) {
119
+ generateApiFrpcProxies(address, port) {
120
+ const { frp } = configs();
121
+ const proxies = [
122
+ {
123
+ name: 'API-' + address,
124
+ localIp: frp.nodeInternalHostName,
125
+ localPorts: port,
126
+ customDomain: address + '.' + frp.serverAddr,
127
+ },
128
+ ];
129
+ return proxies;
130
+ }
131
+ /**
132
+ * Opens an interactive PTY exec session into a running job container for the
133
+ * wallet-authorized web terminal. The authorization check is performed by the
134
+ * caller (the terminal websocket route); this only enforces that the feature
135
+ * is enabled and that we can reach the runtime over a socket.
136
+ */
137
+ async createJobTerminalSession(jobId, options = {}) {
138
+ if (!this.sshEnabled) {
139
+ throw new Error('Web terminal is not enabled');
140
+ }
141
+ return createJobTerminalExec(this.containerOrchestration, jobId, options);
142
+ }
143
+ async authorizeSshKeyForJob(jobId, publicKey, expiresAt) {
144
+ this.assertSshEnabled();
145
+ await this.sshAccess.authorize(jobId, publicKey, expiresAt);
146
+ }
147
+ async listSshKeysForJob(jobId) {
148
+ this.assertSshEnabled();
149
+ return this.sshAccess.list(jobId);
150
+ }
151
+ async revokeSshKeyForJob(jobId, publicKey) {
152
+ this.assertSshEnabled();
153
+ return this.sshAccess.revoke(jobId, publicKey);
154
+ }
155
+ /**
156
+ * SSH requires the runtime to be reached over a socket the sidecar can mount
157
+ * to exec into the workload. Every SSH entry point, including the web
158
+ * terminal, checks this and nothing else.
159
+ */
160
+ get sshEnabled() {
161
+ return this.containerOrchestration.getProtocol() === 'socket';
162
+ }
163
+ assertSshEnabled() {
164
+ if (!this.sshEnabled) {
165
+ throw new SshAccessError('SSH access is not enabled on this node', 503);
166
+ }
167
+ }
168
+ /** The SSH sidecars of a job, one per operation, created ones included. */
169
+ getSshSidecars(jobId) {
170
+ return this.containerOrchestration.getContainersByLabels(sshSidecarFilters(jobId), { all: true });
171
+ }
172
+ /** The runtime socket as a host path, which is what a bind source must be. */
173
+ resolveHostSocketPath() {
174
+ return (this.hostSocketPath ??= this.containerOrchestration.resolveHostPath(this.containerOrchestration.host));
175
+ }
176
+ async taskManagerContainerRunOperation(flow, op, controller, emitter) {
132
177
  let stateManager;
133
178
  let exposedPortHealthCheck;
134
179
  if (controller.signal.aborted) {
@@ -136,9 +181,6 @@ export class Provider {
136
181
  emitter.emit('end');
137
182
  return;
138
183
  }
139
- // The runtime may be being replaced under a repair; nothing starts until
140
- // it answers again.
141
- await this.gpuRuntime?.ready();
142
184
  try {
143
185
  emitter.emit('start');
144
186
  let frpcContainer;
@@ -155,10 +197,16 @@ export class Provider {
155
197
  await this.containerOrchestration.stopAndDeleteContainer(leftover);
156
198
  }
157
199
  }
158
- await this.containerOrchestration.pullImage(op.args.image, op.args.authentication?.docker, controller);
159
- if (!op.args.authentication?.docker) {
160
- this.resourceManager.images.setImage(op.args.image);
161
- }
200
+ const imageAuth = op.args.authentication?.docker;
201
+ // Force a re-pull when a private image is owned by a different project,
202
+ // so this job must re-authenticate against the registry (reusing cached
203
+ // layers) instead of reusing a copy it is not entitled to. The owning
204
+ // project and public images reuse the cache with no pull.
205
+ await this.containerOrchestration.pullImage(op.args.image, imageAuth, controller, this.resourceManager.images.requiresReauth(op.args.image, flow.project));
206
+ // Record the image, tagging it private and owned by this project when it
207
+ // came from an auth-gated registry, so a later job from another project
208
+ // is forced to re-authenticate rather than reuse the cache.
209
+ this.resourceManager.images.setImage(op.args.image, !!imageAuth, imageAuth ? flow.project : undefined);
162
210
  const volumes = getVolumes(op.args, flow);
163
211
  const gpu = getGpu(op.args, flow);
164
212
  const entrypoint = getEntrypoint(op.args, flow);
@@ -178,56 +226,80 @@ export class Provider {
178
226
  const restartsOnUnhealthy = ports.some((port) => port.health_checks?.some((check) => check.restart_on_failure));
179
227
  networks[name] = {};
180
228
  await this.containerOrchestration.createNetwork(name, controller);
181
- if (isOpExposed(op)) {
229
+ const exposed = isOpExposed(op);
230
+ const sshEnabled = this.sshEnabled;
231
+ if (exposed || sshEnabled) {
182
232
  await this.containerOrchestration.pullImage(this.frpcImage, undefined, controller);
183
233
  this.resourceManager.images.setImage(this.frpcImage);
184
- /**
185
- * because of the introduction of deployment_id(load balancing key) instead of using flow.id we use deployment_id for more
186
- * deterministic url generation
187
- */
188
- const config = NodeConfigsSingleton.getInstance();
189
- const isLoadBalanced = !!flow.jobDefinition.deployment_id;
190
- const deploymentHash = isLoadBalanced
191
- ? // @ts-ignore
192
- config.options.isNodeRun
193
- ? flow.jobDefinition.deployment_id
194
- : createHash(`${flow.jobDefinition.deployment_id}:${flow.project}`, 45)
195
- : undefined;
196
- const { proxies, idMap } = generateProxies(flow.id, op, index, ports, name, op.id, deploymentHash);
197
- idMaps = idMap;
198
- frpcContainer = await this.containerOrchestration.runFlowContainer(this.frpcImage, this.generateFrpcContainerConfig(name, networks, flow, proxies, isLoadBalanced, deploymentHash));
199
- if (op.args.private) {
200
- this.repository.updateflowStateSecret(flow.id, {
201
- [flow.id]: generateUrlSecretObject(idMap, op.id),
202
- urlmode: 'private',
203
- });
204
- emitter.emit('flow:secrets-updated', {
205
- flowId: flow.id,
206
- opId: op.id,
207
- });
234
+ const proxies = [];
235
+ let isLoadBalanced = false;
236
+ let deploymentHash;
237
+ if (exposed) {
238
+ /**
239
+ * because of the introduction of deployment_id(load balancing key) instead of using flow.id we use deployment_id for more
240
+ * deterministic url generation
241
+ */
242
+ const config = NodeConfigsSingleton.getInstance();
243
+ isLoadBalanced = !!flow.jobDefinition.deployment_id;
244
+ deploymentHash = isLoadBalanced
245
+ ? // @ts-ignore
246
+ config.options.isNodeRun
247
+ ? flow.jobDefinition.deployment_id
248
+ : createHash(`${flow.jobDefinition.deployment_id}:${flow.project}`, 45)
249
+ : undefined;
250
+ const generated = generateProxies(flow.id, op, index, ports, name, op.id, deploymentHash);
251
+ idMaps = generated.idMap;
252
+ proxies.push(...generated.proxies);
208
253
  }
209
- else {
210
- const newSecret = generateUrlSecretObject(idMap, op.id);
211
- const currentSecrets = this.repository.getFlowSecret(flow.id, flow.id) || {};
212
- const mergedSecrets = {
213
- ...currentSecrets,
214
- ...newSecret,
215
- };
216
- this.repository.updateflowStateSecret(flow.id, {
217
- [flow.id]: mergedSecrets,
218
- urlmode: 'public',
219
- });
220
- emitter.emit('flow:secrets-updated', {
221
- flowId: flow.id,
222
- opId: op.id,
254
+ let sidecarConfig = this.generateFrpcContainerConfig(name, networks, flow, proxies, isLoadBalanced, deploymentHash);
255
+ if (sshEnabled) {
256
+ // Keys are job wide and may have been authorized before this
257
+ // operation started; the seed runs between create and start.
258
+ sidecarConfig = withSshSidecar(sidecarConfig, {
259
+ jobId: flow.id,
260
+ opIndex: index,
261
+ runtime: {
262
+ name: this.containerOrchestration.name,
263
+ host: await this.resolveHostSocketPath(),
264
+ },
265
+ prepare: (sidecar) => this.sshAccess.prepare(flow.id, sidecar),
223
266
  });
224
267
  }
268
+ frpcContainer = await this.containerOrchestration.runFlowContainer(this.frpcImage, sidecarConfig);
269
+ if (exposed) {
270
+ if (op.args.private) {
271
+ this.repository.updateflowStateSecret(flow.id, {
272
+ [flow.id]: generateUrlSecretObject(idMaps, op.id),
273
+ urlmode: 'private',
274
+ });
275
+ emitter.emit('flow:secrets-updated', {
276
+ flowId: flow.id,
277
+ opId: op.id,
278
+ });
279
+ }
280
+ else {
281
+ const newSecret = generateUrlSecretObject(idMaps, op.id);
282
+ const currentSecrets = this.repository.getFlowSecret(flow.id, flow.id) || {};
283
+ const mergedSecrets = {
284
+ ...currentSecrets,
285
+ ...newSecret,
286
+ };
287
+ this.repository.updateflowStateSecret(flow.id, {
288
+ [flow.id]: mergedSecrets,
289
+ urlmode: 'public',
290
+ });
291
+ emitter.emit('flow:secrets-updated', {
292
+ flowId: flow.id,
293
+ opId: op.id,
294
+ });
295
+ }
296
+ }
225
297
  }
226
298
  if (op.args.resources) {
227
299
  await this.containerOrchestration.pullImage(s3HelperImage, undefined, controller);
228
300
  this.resourceManager.images.setImage(s3HelperImage);
229
301
  // transformCollections has already resolved any SpreadMarker objects by this point
230
- const resourceVolumes = await this.resourceManager.getResourceVolumes(op.args.resources ?? [], controller, { id: flow.id, opIndex: index });
302
+ const resourceVolumes = await this.resourceManager.getResourceVolumes(op.args.resources ?? [], controller, { id: flow.id, opIndex: index }, flow.project);
231
303
  volumes.push(...resourceVolumes);
232
304
  }
233
305
  // The op only declares that it wants a TEE; the node's configured
@@ -248,6 +320,11 @@ export class Provider {
248
320
  aliases,
249
321
  restart_policy: op.args.restart_policy,
250
322
  runtime: teeRuntime,
323
+ labels: {
324
+ [JOB_LABEL]: flow.id,
325
+ [OP_LABEL]: op.id,
326
+ [OP_INDEX_LABEL]: index.toString(),
327
+ },
251
328
  };
252
329
  if (teeRuntime === 'SEV-SNP') {
253
330
  await this.containerOrchestration.pullImage("busybox");
@@ -297,44 +374,8 @@ export class Provider {
297
374
  emitter.emit('exit', { exitCode: info.State.ExitCode });
298
375
  }
299
376
  catch (error) {
300
- const fail = () => {
301
- emitter.emit('log', error, 'error');
302
- emitter.emit('error', error);
303
- };
304
- const runtime = this.gpuRuntime;
305
- const message = error instanceof Error ? error.message : String(error);
306
- // The container could not start because the GPU runtime under it went
307
- // stale, which the node can fix: podman is recreated and the operation
308
- // is run again from the top, sidecars included. Once only, and not when
309
- // the recreate's own restart of this operation (the abort) relaunches it.
310
- if (!runtime ||
311
- controller.signal.aborted ||
312
- !(isStaleGpuRuntimeError(error) || runtime.isStale())) {
313
- fail();
314
- }
315
- else if (retried) {
316
- // The recreate did not help: a replaced kernel module takes a reboot.
317
- emitter.emit('log', "The container still cannot start on the GPU runtime after podman was recreated. If the host's NVIDIA driver was replaced, reboot the host.", 'info');
318
- fail();
319
- }
320
- else {
321
- emitter.emit('log', `Container could not start on the GPU runtime, recreating podman and retrying: ${message}`, 'info');
322
- try {
323
- await runtime.recreate(`container start failed on the GPU runtime: ${message}`);
324
- }
325
- catch (recreateError) {
326
- emitter.emit('log', recreateError, 'error');
327
- emitter.emit('error', recreateError);
328
- emitter.emit('end');
329
- return;
330
- }
331
- if (controller.signal.aborted) {
332
- fail();
333
- }
334
- else {
335
- return this.taskManagerContainerRunOperation(flow, op, controller, emitter, true);
336
- }
337
- }
377
+ emitter.emit('log', error, 'error');
378
+ emitter.emit('error', error);
338
379
  }
339
380
  // Clean up resources
340
381
  stateManager?.stopMonitoring();
@@ -0,0 +1,49 @@
1
+ import type { Container } from 'dockerode';
2
+ import type { Flow } from '@nosana/sdk';
3
+ import type { NodeRepository } from '../repository/NodeRepository.js';
4
+ import type { SshKeyGrant } from '../db/index.js';
5
+ export declare class SshAccessError extends Error {
6
+ readonly status: number;
7
+ constructor(message: string, status: number);
8
+ }
9
+ /**
10
+ * A job accepts SSH changes until its flow reaches a final status. A flow the
11
+ * node resumes after a restart keeps its stale endTime, so that alone does not
12
+ * mean the job has ended.
13
+ */
14
+ export declare function isActiveSshFlow(flow: Flow | undefined): boolean;
15
+ export declare function validateSshKey(value: unknown): string;
16
+ export declare function validateSshExpiry(value: unknown, now?: number): string | undefined;
17
+ /**
18
+ * Owns a job's effective SSH key set. The node database is the source of
19
+ * truth; every running sidecar of the job is written from it, and a sidecar
20
+ * created later starts from it, so keys authorized mid-job reach operations
21
+ * that have not started yet.
22
+ */
23
+ export declare class SshAccessManager {
24
+ private repository;
25
+ private sidecars;
26
+ private now;
27
+ private queues;
28
+ constructor(repository: NodeRepository, sidecars: (jobId: string) => Promise<Container[]>, now?: () => number);
29
+ private serial;
30
+ private read;
31
+ /** The saved set, or the definition's keys before the first save, minus expired grants. */
32
+ private effective;
33
+ list(jobId: string): Promise<SshKeyGrant[]>;
34
+ private update;
35
+ authorize(jobId: string, sshPublicKey: string, expiresAt?: string): Promise<void>;
36
+ revoke(jobId: string, sshPublicKey: string): Promise<void>;
37
+ /**
38
+ * Seeds a created, not yet started sidecar with the job's host key and its
39
+ * current effective keys. Only a host key that did not exist yet is saved.
40
+ * There is no liveness check: a flow the node resumes must get its sidecars
41
+ * back.
42
+ */
43
+ prepare(jobId: string, sidecar: Container): Promise<void>;
44
+ /**
45
+ * One host key per job, kept in the database so every sidecar of the job,
46
+ * including recreated ones and later operations, presents the same identity.
47
+ */
48
+ private hostKey;
49
+ }
@@ -0,0 +1,148 @@
1
+ import { getSshKeyIdentity, isValidSshPublicKey, requireFutureSshExpiry, MAX_SSH_PUBLIC_KEYS, MAX_SSH_PUBLIC_KEYS_BYTES, } from '@nosana/ssh';
2
+ import { extractSshPublicKeys, generateSshHostKey, replaceSshAuthorizedKeys, seedSshSidecar, } from './sshSidecar.js';
3
+ export class SshAccessError extends Error {
4
+ constructor(message, status) {
5
+ super(message);
6
+ this.status = status;
7
+ }
8
+ }
9
+ /**
10
+ * A job accepts SSH changes until its flow reaches a final status. A flow the
11
+ * node resumes after a restart keeps its stale endTime, so that alone does not
12
+ * mean the job has ended.
13
+ */
14
+ export function isActiveSshFlow(flow) {
15
+ return (!!flow && !['success', 'failed', 'stopped'].includes(flow.state?.status));
16
+ }
17
+ export function validateSshKey(value) {
18
+ if (typeof value !== 'string' || !isValidSshPublicKey(value)) {
19
+ throw new SshAccessError('Invalid SSH public key', 400);
20
+ }
21
+ return value.trim();
22
+ }
23
+ export function validateSshExpiry(value, now = Date.now()) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ try {
27
+ return requireFutureSshExpiry(value, now);
28
+ }
29
+ catch (error) {
30
+ throw new SshAccessError(error instanceof Error ? error.message : 'Invalid SSH expiry', 400);
31
+ }
32
+ }
33
+ function checkLimits(keys) {
34
+ if (keys.length > MAX_SSH_PUBLIC_KEYS ||
35
+ keys.reduce((n, key) => n + Buffer.byteLength(key.sshPublicKey), 0) >
36
+ MAX_SSH_PUBLIC_KEYS_BYTES) {
37
+ throw new SshAccessError(`A job can contain at most ${MAX_SSH_PUBLIC_KEYS} SSH keys and ${MAX_SSH_PUBLIC_KEYS_BYTES / 1024} KiB of key data`, 400);
38
+ }
39
+ }
40
+ /**
41
+ * Owns a job's effective SSH key set. The node database is the source of
42
+ * truth; every running sidecar of the job is written from it, and a sidecar
43
+ * created later starts from it, so keys authorized mid-job reach operations
44
+ * that have not started yet.
45
+ */
46
+ export class SshAccessManager {
47
+ constructor(repository, sidecars, now = Date.now) {
48
+ this.repository = repository;
49
+ this.sidecars = sidecars;
50
+ this.now = now;
51
+ this.queues = new Map();
52
+ }
53
+ async serial(jobId, action) {
54
+ const previous = this.queues.get(jobId) ?? Promise.resolve();
55
+ const current = previous.catch(() => { }).then(action);
56
+ this.queues.set(jobId, current);
57
+ try {
58
+ return await current;
59
+ }
60
+ finally {
61
+ if (this.queues.get(jobId) === current)
62
+ this.queues.delete(jobId);
63
+ }
64
+ }
65
+ read(jobId) {
66
+ const flow = this.repository.getFlow(jobId);
67
+ if (!flow || !isActiveSshFlow(flow))
68
+ throw new SshAccessError('Could not find active job', 404);
69
+ return this.effective(flow);
70
+ }
71
+ /** The saved set, or the definition's keys before the first save, minus expired grants. */
72
+ effective(flow) {
73
+ const stored = this.repository.getSshKeyGrants(flow.id);
74
+ const keys = stored ??
75
+ extractSshPublicKeys(flow.jobDefinition).map((sshPublicKey) => ({
76
+ sshPublicKey,
77
+ }));
78
+ const unique = new Map();
79
+ for (const key of keys) {
80
+ // Drops expired grants and unparsable expiries (NaN fails the comparison).
81
+ if (key.expiresAt !== undefined && !(Date.parse(key.expiresAt) > this.now()))
82
+ continue;
83
+ unique.set(getSshKeyIdentity(key.sshPublicKey), { ...key });
84
+ }
85
+ return [...unique.values()];
86
+ }
87
+ async list(jobId) {
88
+ return this.read(jobId);
89
+ }
90
+ update(jobId, change) {
91
+ return this.serial(jobId, async () => {
92
+ const keys = change(this.read(jobId));
93
+ checkLimits(keys);
94
+ // Save desired state first. A failed sidecar write must not resurrect
95
+ // revoked keys later; callers receive an error and can safely retry.
96
+ this.repository.setSshKeyGrants(jobId, keys);
97
+ // Listed after the save and inside the queue, so a sidecar seeded just
98
+ // ahead of this update, started or not, is written as well.
99
+ const sidecars = await this.sidecars(jobId);
100
+ await Promise.all(sidecars.map((sidecar) => replaceSshAuthorizedKeys(sidecar, keys)));
101
+ return keys;
102
+ });
103
+ }
104
+ async authorize(jobId, sshPublicKey, expiresAt) {
105
+ sshPublicKey = validateSshKey(sshPublicKey);
106
+ expiresAt = validateSshExpiry(expiresAt, this.now());
107
+ await this.update(jobId, (keys) => {
108
+ // An expiry must still be valid after waiting for another update.
109
+ validateSshExpiry(expiresAt, this.now());
110
+ return [
111
+ ...keys.filter((key) => getSshKeyIdentity(key.sshPublicKey) !==
112
+ getSshKeyIdentity(sshPublicKey)),
113
+ { sshPublicKey, ...(expiresAt === undefined ? {} : { expiresAt }) },
114
+ ];
115
+ });
116
+ }
117
+ async revoke(jobId, sshPublicKey) {
118
+ const identity = getSshKeyIdentity(validateSshKey(sshPublicKey));
119
+ await this.update(jobId, (keys) => keys.filter((key) => getSshKeyIdentity(key.sshPublicKey) !== identity));
120
+ }
121
+ /**
122
+ * Seeds a created, not yet started sidecar with the job's host key and its
123
+ * current effective keys. Only a host key that did not exist yet is saved.
124
+ * There is no liveness check: a flow the node resumes must get its sidecars
125
+ * back.
126
+ */
127
+ async prepare(jobId, sidecar) {
128
+ await this.serial(jobId, () => {
129
+ const flow = this.repository.getFlow(jobId);
130
+ return seedSshSidecar(sidecar, {
131
+ hostKey: this.hostKey(jobId),
132
+ keys: flow ? this.effective(flow) : [],
133
+ });
134
+ });
135
+ }
136
+ /**
137
+ * One host key per job, kept in the database so every sidecar of the job,
138
+ * including recreated ones and later operations, presents the same identity.
139
+ */
140
+ hostKey(jobId) {
141
+ const stored = this.repository.getSshHostKey(jobId);
142
+ if (stored)
143
+ return stored;
144
+ const generated = generateSshHostKey();
145
+ this.repository.setSshHostKey(jobId, generated);
146
+ return generated;
147
+ }
148
+ }
@@ -1,3 +1,3 @@
1
1
  import { ContainerCreateOptions } from "dockerode";
2
2
  import { RunContainerArgs } from "../interface";
3
- export declare function createDockerRunOptions(image: string, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container }: RunContainerArgs, gpuOption: string): ContainerCreateOptions;
3
+ export declare function createDockerRunOptions(image: string, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container, labels, security, }: RunContainerArgs, gpuOption: string): ContainerCreateOptions;