@nosana/node 1.1.63-rc → 1.1.65-rc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/.env +2 -4
- package/dist/.env.dev +1 -1
- package/dist/.env.prd +1 -1
- package/dist/package.json +3 -2
- package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
- package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
- package/dist/src/NodeManager/configs/configs.d.ts +0 -3
- package/dist/src/NodeManager/configs/configs.js +2 -5
- package/dist/src/NodeManager/db/index.d.ts +23 -0
- package/dist/src/NodeManager/index.js +9 -4
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.d.ts +2 -3
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.js +8 -12
- package/dist/src/NodeManager/node/Node.d.ts +0 -3
- package/dist/src/NodeManager/node/Node.js +0 -32
- package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
- package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
- package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
- package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
- package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
- package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
- package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
- package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
- package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
- package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
- package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
- package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
- package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
- package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
- package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
- package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
- package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
- package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +0 -8
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +0 -22
- package/dist/src/NodeManager/provider/Provider.d.ts +30 -14
- package/dist/src/NodeManager/provider/Provider.js +157 -116
- package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
- package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
- package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
- package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
- package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
- package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
- package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
- package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
- package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
- package/dist/src/NodeManager/provider/webTerminal.js +98 -0
- package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
- package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
- package/dist/src/cli/commands/prune/action.js +1 -2
- package/dist/src/cli/commands/run/action.js +1 -14
- package/dist/src/cli/commands/run/command.js +1 -2
- package/dist/src/cli/commands/sharedOptions/index.d.ts +0 -1
- package/dist/src/cli/commands/sharedOptions/index.js +0 -1
- package/dist/src/cli/commands/start/action.js +1 -2
- package/dist/src/cli/commands/start/command.js +1 -2
- package/npm-shrinkwrap.json +28 -2
- package/package.json +3 -2
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
- package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
- package/dist/src/NodeManager/provider/gpuRuntime.d.ts +0 -13
- package/dist/src/NodeManager/provider/gpuRuntime.js +0 -27
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.d.ts +0 -72
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.js +0 -238
- package/dist/src/cli/commands/sharedOptions/--managed-podman.d.ts +0 -16
- package/dist/src/cli/commands/sharedOptions/--managed-podman.js +0 -25
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
36
|
+
const doesFrpcExist = await this.containerOrchestration.doesContainerExist(frpc_name);
|
|
34
37
|
if (doesFrpcExist) {
|
|
35
|
-
await this.
|
|
38
|
+
await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
|
|
36
39
|
}
|
|
37
40
|
// check if network then delete
|
|
38
|
-
if (await this.
|
|
39
|
-
await this.
|
|
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.
|
|
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.
|
|
60
|
+
await this.containerOrchestration.pullImage(this.frpcImage, undefined, startup);
|
|
59
61
|
this.resourceManager.images.setImage(this.frpcImage);
|
|
60
|
-
const doesFrpcExist = await this.
|
|
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.
|
|
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.
|
|
89
|
+
const hasFrpcExited = await this.containerOrchestration.isContainerExited(frpc_name);
|
|
95
90
|
if (hasFrpcExited) {
|
|
96
|
-
await this.
|
|
97
|
-
await this.
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
+
}
|
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;
|