@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.
- package/dist/.env +2 -3
- package/dist/.env.dev +1 -1
- package/dist/.env.prd +1 -1
- package/dist/package.json +3 -2
- package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
- package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
- package/dist/src/NodeManager/configs/configs.js +2 -2
- package/dist/src/NodeManager/db/index.d.ts +23 -0
- package/dist/src/NodeManager/index.js +8 -3
- package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
- package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
- package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
- package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
- package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
- package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
- package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
- package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
- package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
- package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
- package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
- package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
- package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
- package/dist/src/NodeManager/provider/Provider.d.ts +29 -0
- package/dist/src/NodeManager/provider/Provider.js +143 -61
- package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
- package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
- package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
- package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
- package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
- package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
- package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
- package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
- package/dist/src/NodeManager/provider/webTerminal.js +98 -0
- package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
- package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
- package/npm-shrinkwrap.json +28 -2
- package/package.json +3 -2
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js
CHANGED
|
@@ -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
|
-
|
|
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;
|