@nosana/node 1.1.62-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 (61) hide show
  1. package/dist/.env +2 -3
  2. package/dist/.env.dev +1 -1
  3. package/dist/.env.prd +1 -1
  4. package/dist/package.json +3 -2
  5. package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
  6. package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
  7. package/dist/src/NodeManager/configs/configs.js +2 -2
  8. package/dist/src/NodeManager/db/index.d.ts +23 -0
  9. package/dist/src/NodeManager/index.js +8 -3
  10. package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
  11. package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
  12. package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
  13. package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
  14. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
  15. package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
  16. package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
  17. package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
  18. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
  19. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
  20. package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
  21. package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
  22. package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
  23. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
  24. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
  25. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
  26. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
  27. package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
  28. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
  29. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
  30. package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
  31. package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
  32. package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
  33. package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
  34. package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
  35. package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
  36. package/dist/src/NodeManager/provider/Provider.d.ts +29 -0
  37. package/dist/src/NodeManager/provider/Provider.js +143 -61
  38. package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
  39. package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
  40. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
  41. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
  42. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
  43. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
  44. package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
  45. package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
  46. package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
  47. package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
  48. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
  49. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
  50. package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
  51. package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
  52. package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
  53. package/dist/src/NodeManager/provider/webTerminal.js +98 -0
  54. package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
  55. package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
  56. package/npm-shrinkwrap.json +28 -2
  57. package/package.json +3 -2
  58. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
  59. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
  60. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
  61. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
@@ -8,6 +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 { 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';
11
15
  export class Provider {
12
16
  constructor(containerOrchestration, repository, resourceManager) {
13
17
  this.containerOrchestration = containerOrchestration;
@@ -15,6 +19,7 @@ export class Provider {
15
19
  this.resourceManager = resourceManager;
16
20
  this.frpcImage = configs().frp.containerImage;
17
21
  this.proxyStartupAbortController = undefined;
22
+ this.sshAccess = new SshAccessManager(repository, (jobId) => this.getSshSidecars(jobId));
18
23
  applyLoggingProxyToClass(this);
19
24
  }
20
25
  async hasTrustedExecutionEnvCapability() {
@@ -45,7 +50,6 @@ export class Provider {
45
50
  // set up reverse proxy api for api handler
46
51
  async setUpReverseProxyApi(address, port) {
47
52
  try {
48
- const nodeName = configs().frp.nodeInternalHostName;
49
53
  const networkName = `api-${address}`;
50
54
  const frpc_name = `frpc-api-${address}`;
51
55
  await this.containerOrchestration.createNetwork(networkName);
@@ -71,14 +75,7 @@ export class Provider {
71
75
  env: {
72
76
  FRP_SERVER_ADDR: configs().frp.serverAddr,
73
77
  FRP_SERVER_PORT: configs().frp.serverPort.toString(),
74
- FRP_PROXIES: JSON.stringify([
75
- {
76
- name: 'API-' + address,
77
- localIp: nodeName,
78
- localPorts: port.toString(),
79
- customDomain: address + '.' + configs().frp.serverAddr,
80
- },
81
- ]),
78
+ FRP_PROXIES: JSON.stringify(this.generateApiFrpcProxies(address, port)),
82
79
  },
83
80
  volumes: [
84
81
  {
@@ -101,14 +98,7 @@ export class Provider {
101
98
  env: {
102
99
  FRP_SERVER_ADDR: configs().frp.serverAddr,
103
100
  FRP_SERVER_PORT: configs().frp.serverPort.toString(),
104
- FRP_PROXIES: JSON.stringify([
105
- {
106
- name: 'API-' + address,
107
- localIp: nodeName,
108
- localPorts: port.toString(),
109
- customDomain: address + '.' + configs().frp.serverAddr,
110
- },
111
- ]),
101
+ FRP_PROXIES: JSON.stringify(this.generateApiFrpcProxies(address, port)),
112
102
  },
113
103
  volumes: [
114
104
  {
@@ -126,6 +116,63 @@ export class Provider {
126
116
  this.proxyStartupAbortController = undefined;
127
117
  return true;
128
118
  }
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
+ }
129
176
  async taskManagerContainerRunOperation(flow, op, controller, emitter) {
130
177
  let stateManager;
131
178
  let exposedPortHealthCheck;
@@ -150,10 +197,16 @@ export class Provider {
150
197
  await this.containerOrchestration.stopAndDeleteContainer(leftover);
151
198
  }
152
199
  }
153
- await this.containerOrchestration.pullImage(op.args.image, op.args.authentication?.docker, controller);
154
- if (!op.args.authentication?.docker) {
155
- this.resourceManager.images.setImage(op.args.image);
156
- }
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);
157
210
  const volumes = getVolumes(op.args, flow);
158
211
  const gpu = getGpu(op.args, flow);
159
212
  const entrypoint = getEntrypoint(op.args, flow);
@@ -173,56 +226,80 @@ export class Provider {
173
226
  const restartsOnUnhealthy = ports.some((port) => port.health_checks?.some((check) => check.restart_on_failure));
174
227
  networks[name] = {};
175
228
  await this.containerOrchestration.createNetwork(name, controller);
176
- if (isOpExposed(op)) {
229
+ const exposed = isOpExposed(op);
230
+ const sshEnabled = this.sshEnabled;
231
+ if (exposed || sshEnabled) {
177
232
  await this.containerOrchestration.pullImage(this.frpcImage, undefined, controller);
178
233
  this.resourceManager.images.setImage(this.frpcImage);
179
- /**
180
- * because of the introduction of deployment_id(load balancing key) instead of using flow.id we use deployment_id for more
181
- * deterministic url generation
182
- */
183
- const config = NodeConfigsSingleton.getInstance();
184
- const isLoadBalanced = !!flow.jobDefinition.deployment_id;
185
- const deploymentHash = isLoadBalanced
186
- ? // @ts-ignore
187
- config.options.isNodeRun
188
- ? flow.jobDefinition.deployment_id
189
- : createHash(`${flow.jobDefinition.deployment_id}:${flow.project}`, 45)
190
- : undefined;
191
- const { proxies, idMap } = generateProxies(flow.id, op, index, ports, name, op.id, deploymentHash);
192
- idMaps = idMap;
193
- frpcContainer = await this.containerOrchestration.runFlowContainer(this.frpcImage, this.generateFrpcContainerConfig(name, networks, flow, proxies, isLoadBalanced, deploymentHash));
194
- if (op.args.private) {
195
- this.repository.updateflowStateSecret(flow.id, {
196
- [flow.id]: generateUrlSecretObject(idMap, op.id),
197
- urlmode: 'private',
198
- });
199
- emitter.emit('flow:secrets-updated', {
200
- flowId: flow.id,
201
- opId: op.id,
202
- });
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);
203
253
  }
204
- else {
205
- const newSecret = generateUrlSecretObject(idMap, op.id);
206
- const currentSecrets = this.repository.getFlowSecret(flow.id, flow.id) || {};
207
- const mergedSecrets = {
208
- ...currentSecrets,
209
- ...newSecret,
210
- };
211
- this.repository.updateflowStateSecret(flow.id, {
212
- [flow.id]: mergedSecrets,
213
- urlmode: 'public',
214
- });
215
- emitter.emit('flow:secrets-updated', {
216
- flowId: flow.id,
217
- 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),
218
266
  });
219
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
+ }
220
297
  }
221
298
  if (op.args.resources) {
222
299
  await this.containerOrchestration.pullImage(s3HelperImage, undefined, controller);
223
300
  this.resourceManager.images.setImage(s3HelperImage);
224
301
  // transformCollections has already resolved any SpreadMarker objects by this point
225
- 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);
226
303
  volumes.push(...resourceVolumes);
227
304
  }
228
305
  // The op only declares that it wants a TEE; the node's configured
@@ -243,6 +320,11 @@ export class Provider {
243
320
  aliases,
244
321
  restart_policy: op.args.restart_policy,
245
322
  runtime: teeRuntime,
323
+ labels: {
324
+ [JOB_LABEL]: flow.id,
325
+ [OP_LABEL]: op.id,
326
+ [OP_INDEX_LABEL]: index.toString(),
327
+ },
246
328
  };
247
329
  if (teeRuntime === 'SEV-SNP') {
248
330
  await this.containerOrchestration.pullImage("busybox");
@@ -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;
@@ -10,7 +10,7 @@ function parseRestartPolicy(restart_policy) {
10
10
  }
11
11
  return undefined;
12
12
  }
13
- export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container }, gpuOption) {
13
+ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container, labels, security, }, gpuOption) {
14
14
  const devices = [];
15
15
  if (gpu) {
16
16
  devices.push({
@@ -37,6 +37,7 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
37
37
  Image: image,
38
38
  WorkingDir: work_dir,
39
39
  Entrypoint: entrypoint,
40
+ Labels: labels,
40
41
  ...(!bind_network_to_container ? {
41
42
  NetworkingConfig: {
42
43
  EndpointsConfig: {
@@ -51,10 +52,10 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
51
52
  'host.containers.internal:8.8.8.8',
52
53
  ]
53
54
  } : {}),
54
- Mounts: volumes?.map(({ dest, name, readonly }) => ({
55
+ Mounts: volumes?.map(({ dest, name, readonly, type }) => ({
55
56
  Target: dest,
56
57
  Source: name,
57
- Type: 'volume',
58
+ Type: type ?? 'volume',
58
59
  ReadOnly: readonly || false,
59
60
  })),
60
61
  NetworkMode: bind_network_to_container ? bind_network_to_container : 'bridge',
@@ -70,6 +71,8 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
70
71
  } : {}),
71
72
  RestartPolicy: parseRestartPolicy(restart_policy),
72
73
  ...(runtime === 'SEV-SNP' ? { Runtime: runtime } : {}),
74
+ ...(security?.capDrop ? { CapDrop: security.capDrop } : {}),
75
+ ...(security?.pidsLimit ? { PidsLimit: security.pidsLimit } : {}),
73
76
  },
74
77
  };
75
78
  }
@@ -16,8 +16,12 @@ export declare class DockerContainerOrchestration implements ContainerOrchestrat
16
16
  checkCustomRuntimeAvailability(runtime: string): Promise<boolean>;
17
17
  getContainerByName(name: string): Promise<Container | undefined>;
18
18
  getContainersByName(names: string[]): Promise<Container[]>;
19
+ getContainersByLabels(labels: string[], options?: {
20
+ all?: boolean;
21
+ }): Promise<Container[]>;
22
+ resolveHostPath(path: string): Promise<string>;
19
23
  getConnection(): DockerExtended;
20
- pullImage(image: string, authorisation?: DockerAuth, controller?: AbortController): Promise<void>;
24
+ pullImage(image: string, authorisation?: DockerAuth, controller?: AbortController, force?: boolean): Promise<void>;
21
25
  hasImage(image: string): Promise<boolean>;
22
26
  getImage(image: string): Promise<Image>;
23
27
  listImages(): Promise<ImageInfo[]>;
@@ -73,14 +73,50 @@ export class DockerContainerOrchestration {
73
73
  throw new Error('could not get containers');
74
74
  }
75
75
  }
76
+ async getContainersByLabels(labels, options = {}) {
77
+ try {
78
+ const containers = await this.docker.listContainers({
79
+ all: options.all ?? false,
80
+ filters: {
81
+ label: labels,
82
+ ...(options.all ? {} : { status: ['running'] }),
83
+ },
84
+ });
85
+ return containers.map((info) => this.docker.getContainer(info.Id));
86
+ }
87
+ catch (_) {
88
+ throw new Error('could not get containers');
89
+ }
90
+ }
91
+ async resolveHostPath(path) {
92
+ try {
93
+ // A container's hostname is its id unless overridden, so this finds the
94
+ // node's own container; on a bare host the lookup fails and the path is
95
+ // already a host path.
96
+ const self = await this.docker.getContainer(os.hostname()).inspect();
97
+ const mount = self.Mounts?.find((m) => path === m.Destination || path.startsWith(`${m.Destination}/`));
98
+ if (!mount?.Source)
99
+ return path;
100
+ return mount.Source + path.slice(mount.Destination.length);
101
+ }
102
+ catch (_) {
103
+ return path;
104
+ }
105
+ }
76
106
  getConnection() {
77
107
  return this.docker;
78
108
  }
79
- async pullImage(image, authorisation, controller) {
109
+ async pullImage(image, authorisation, controller, force = false) {
80
110
  try {
81
111
  if (controller?.signal.aborted)
82
112
  throw controller.signal.reason;
83
- if (await this.docker.hasImage(image))
113
+ // A cached image is reused unless the caller forces a pull. `force` is set
114
+ // for a private image a different project owns: the pull then re-verifies
115
+ // this project's credentials against the registry (reusing cached layers)
116
+ // and fails a job that cannot authenticate, rather than handing it a copy
117
+ // another project pulled. The owning project and public images reuse the
118
+ // cache with no registry round-trip, so a cached node stays usable offline.
119
+ if (!force && (await this.docker.hasImage(image)))
84
120
  return;
85
121
  await this.docker.promisePull(image, controller ?? new AbortController(), authorisation);
86
122
  }
@@ -236,6 +272,8 @@ export class DockerContainerOrchestration {
236
272
  ...createDockerRunOptions(image, args, this.gpu),
237
273
  });
238
274
  try {
275
+ if (args.beforeStart)
276
+ await args.beforeStart(container);
239
277
  await container.start();
240
278
  }
241
279
  catch (error) {
@@ -13,7 +13,9 @@ export type RunContainerArgs = {
13
13
  dest: string;
14
14
  name: string;
15
15
  readonly?: boolean;
16
+ type?: 'volume' | 'bind';
16
17
  }>;
18
+ labels?: Record<string, string>;
17
19
  env?: {
18
20
  [key: string]: string;
19
21
  };
@@ -23,11 +25,23 @@ export type RunContainerArgs = {
23
25
  aliases?: string[] | undefined;
24
26
  runtime?: string;
25
27
  bind_network_to_container?: string;
28
+ security?: {
29
+ capDrop?: string[];
30
+ pidsLimit?: number;
31
+ };
32
+ /**
33
+ * Runs between create and start with the container handle, so files can be
34
+ * put in place before the entrypoint runs. A failure removes the created
35
+ * container and fails the run.
36
+ */
37
+ beforeStart?: (container: Container) => Promise<void>;
26
38
  };
27
39
  export interface ContainerOrchestrationInterface {
28
40
  teeRuntime?: string;
41
+ name: string;
42
+ host: string;
29
43
  getConnection(): any;
30
- pullImage(image: string, auth?: DockerAuth, controller?: AbortController): Promise<void>;
44
+ pullImage(image: string, auth?: DockerAuth, controller?: AbortController, force?: boolean): Promise<void>;
31
45
  hasImage(image: string): Promise<boolean>;
32
46
  getImage(image: string): Promise<Image>;
33
47
  listImages(): Promise<ImageInfo[]>;
@@ -43,6 +57,19 @@ export interface ContainerOrchestrationInterface {
43
57
  deleteVolume(name: string, controller?: AbortController): Promise<void>;
44
58
  getContainerByName(name: string): Promise<Container | undefined>;
45
59
  getContainersByName(names: string[]): Promise<Container[]>;
60
+ /**
61
+ * Containers matching every filter, each `key=value` or a bare `key` that
62
+ * only has to be present. Running ones unless `all` is set.
63
+ */
64
+ getContainersByLabels(labels: string[], options?: {
65
+ all?: boolean;
66
+ }): Promise<Container[]>;
67
+ /**
68
+ * The host-side path behind a path the node sees. The node may run in a
69
+ * container with the runtime socket mounted elsewhere, and the daemon
70
+ * resolves bind sources on the host.
71
+ */
72
+ resolveHostPath(path: string): Promise<string>;
46
73
  runContainer(args: ContainerCreateOptions, controller?: AbortController): Promise<Container>;
47
74
  runFlowContainer(image: string, args: RunContainerArgs): Promise<Container>;
48
75
  stopContainer(id: string, controller?: AbortController): Promise<void>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Labels the node stamps on the containers it runs, so they can be found
3
+ * again through the runtime instead of by name.
4
+ */
5
+ export declare const JOB_LABEL = "com.nosana.job";
6
+ export declare const OP_LABEL = "com.nosana.op";
7
+ export declare const OP_INDEX_LABEL = "com.nosana.opIndex";
8
+ export declare const SERVICE_LABEL = "com.nosana.service";
9
+ /** Job and operation ids become container names and label values. */
10
+ export declare function isSafeIdentifier(value: string): boolean;