@nosana/node 1.1.61-rc → 1.1.63-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 +1 -0
- package/dist/.env +1 -0
- package/dist/package.json +1 -1
- package/dist/src/NodeManager/configs/configs.d.ts +3 -0
- package/dist/src/NodeManager/configs/configs.js +3 -0
- package/dist/src/NodeManager/index.js +1 -1
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.d.ts +3 -2
- package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.js +12 -8
- package/dist/src/NodeManager/node/Node.d.ts +3 -0
- package/dist/src/NodeManager/node/Node.js +32 -0
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +8 -0
- package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +22 -0
- package/dist/src/NodeManager/provider/Provider.d.ts +14 -1
- package/dist/src/NodeManager/provider/Provider.js +55 -14
- package/dist/src/NodeManager/provider/gpuRuntime.d.ts +13 -0
- package/dist/src/NodeManager/provider/gpuRuntime.js +27 -0
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.d.ts +72 -0
- package/dist/src/NodeManager/provider/podmanManager/PodmanManager.js +238 -0
- package/dist/src/NodeManager/utils/expose-util.js +3 -1
- package/dist/src/cli/commands/prune/action.js +2 -1
- package/dist/src/cli/commands/run/action.js +14 -1
- package/dist/src/cli/commands/run/command.js +2 -1
- package/dist/src/cli/commands/run/generateDeploymentEndpointsTable.js +6 -3
- package/dist/src/cli/commands/sharedOptions/--managed-podman.d.ts +16 -0
- package/dist/src/cli/commands/sharedOptions/--managed-podman.js +25 -0
- package/dist/src/cli/commands/sharedOptions/index.d.ts +1 -0
- package/dist/src/cli/commands/sharedOptions/index.js +1 -0
- package/dist/src/cli/commands/start/action.js +2 -1
- package/dist/src/cli/commands/start/command.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ Options:
|
|
|
80
80
|
--provider <provider> provider used to run the job (choices: "docker", "podman", default: "podman")
|
|
81
81
|
-w, --wallet <wallet> path to wallet private key (default: "~/.nosana/nosana_key.json")
|
|
82
82
|
--docker, --podman <URI> Podman/Docker connection URI (default: "~/.nosana/podman/podman.sock")
|
|
83
|
+
--managed-podman Run jobs in a podman container the node creates over the docker socket given with --docker, and recreates when its GPU mounts go stale
|
|
83
84
|
-h, --help display help for command
|
|
84
85
|
```
|
|
85
86
|
|
package/dist/.env
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
BACKEND_SOLANA_ADDRESS=7rFPFnxjXHC2sfDy3qrDa9pEb4j49oivMuV7e8sYDPmB
|
|
2
2
|
FRPC_CONTAINER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.44
|
|
3
3
|
FRP_SERVER_IMAGE=registry.hub.docker.com/nosana/frpc:v1.0.44
|
|
4
|
+
PODMAN_CONTAINER_IMAGE=docker.io/nosana/podman:v1.1.2
|
|
4
5
|
FRP_SERVER_PORT=7000
|
|
5
6
|
MIN_DISK_SPACE=25
|
|
6
7
|
SIGN_MESSAGE="Hello Nosana Node!"
|
package/dist/package.json
CHANGED
|
@@ -13,6 +13,9 @@ export const configs = (options) => {
|
|
|
13
13
|
containerImage: loadConfigurationValue('FRP_SERVER_IMAGE'),
|
|
14
14
|
nodeInternalHostName: loadConfigurationValue('FRP_NODE_INTERNAL_HOSTNAME', "nosana-node"),
|
|
15
15
|
},
|
|
16
|
+
podman: {
|
|
17
|
+
containerImage: loadConfigurationValue('PODMAN_CONTAINER_IMAGE'),
|
|
18
|
+
},
|
|
16
19
|
api: {
|
|
17
20
|
port: parseInt(loadConfigurationValue('API_PORT')),
|
|
18
21
|
},
|
|
@@ -64,7 +64,7 @@ export default class NodeManager {
|
|
|
64
64
|
* collect the diagnostics the podman container leaves in the shared
|
|
65
65
|
* directory, which has no other route off the node
|
|
66
66
|
*/
|
|
67
|
-
reportPodmanDiagnostics(this.configLocation);
|
|
67
|
+
reportPodmanDiagnostics(this.configLocation, (event) => this.node.podmanEvent(event));
|
|
68
68
|
/**
|
|
69
69
|
* start
|
|
70
70
|
*
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Forward the events the podman container leaves in the directory the two
|
|
3
3
|
* share. That container has no route to the host manager of its own, so its
|
|
4
|
-
* event log is the only sign of whether the CDI watcher ever fired.
|
|
4
|
+
* event log is the only sign of whether the CDI watcher ever fired. Each
|
|
5
|
+
* event also goes to `onEvent`, for the node to act on.
|
|
5
6
|
*
|
|
6
7
|
* Nothing here may throw: this is diagnostics, and the node's uncaught handler
|
|
7
8
|
* would turn a failure to read a log file into the node stopping. Watching can
|
|
@@ -9,4 +10,4 @@
|
|
|
9
10
|
* and a watch already established still reports errors of its own if the
|
|
10
11
|
* directory it covers is removed.
|
|
11
12
|
*/
|
|
12
|
-
export declare function reportPodmanDiagnostics(configLocation: string): void;
|
|
13
|
+
export declare function reportPodmanDiagnostics(configLocation: string, onEvent?: (event: string) => void): void;
|
|
@@ -10,7 +10,8 @@ const MAX_CATCH_UP = 1024 * 1024;
|
|
|
10
10
|
/**
|
|
11
11
|
* Forward the events the podman container leaves in the directory the two
|
|
12
12
|
* share. That container has no route to the host manager of its own, so its
|
|
13
|
-
* event log is the only sign of whether the CDI watcher ever fired.
|
|
13
|
+
* event log is the only sign of whether the CDI watcher ever fired. Each
|
|
14
|
+
* event also goes to `onEvent`, for the node to act on.
|
|
14
15
|
*
|
|
15
16
|
* Nothing here may throw: this is diagnostics, and the node's uncaught handler
|
|
16
17
|
* would turn a failure to read a log file into the node stopping. Watching can
|
|
@@ -18,7 +19,7 @@ const MAX_CATCH_UP = 1024 * 1024;
|
|
|
18
19
|
* and a watch already established still reports errors of its own if the
|
|
19
20
|
* directory it covers is removed.
|
|
20
21
|
*/
|
|
21
|
-
export function reportPodmanDiagnostics(configLocation) {
|
|
22
|
+
export function reportPodmanDiagnostics(configLocation, onEvent) {
|
|
22
23
|
if (!configLocation)
|
|
23
24
|
return;
|
|
24
25
|
const log = path.join(configLocation.replace(/^~/, os.homedir()), CDI_EVENTS);
|
|
@@ -75,12 +76,15 @@ export function reportPodmanDiagnostics(configLocation) {
|
|
|
75
76
|
partial = lines.pop() ?? '';
|
|
76
77
|
lines
|
|
77
78
|
.filter(Boolean)
|
|
78
|
-
.forEach((event) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
.forEach((event) => {
|
|
80
|
+
void reportError({
|
|
81
|
+
error_type: 'cdiEvent',
|
|
82
|
+
error_name: 'Event',
|
|
83
|
+
error_message: event,
|
|
84
|
+
error_stack: '',
|
|
85
|
+
});
|
|
86
|
+
onEvent?.(event);
|
|
87
|
+
});
|
|
84
88
|
}
|
|
85
89
|
finally {
|
|
86
90
|
fs.closeSync(fd);
|
|
@@ -15,6 +15,7 @@ export declare class BasicNode {
|
|
|
15
15
|
private resourceManager;
|
|
16
16
|
private provider;
|
|
17
17
|
private containerOrchestration;
|
|
18
|
+
private podmanManager?;
|
|
18
19
|
private exiting;
|
|
19
20
|
private sdk;
|
|
20
21
|
constructor(options: {
|
|
@@ -27,6 +28,8 @@ export declare class BasicNode {
|
|
|
27
28
|
isApiActive(): Promise<boolean>;
|
|
28
29
|
node(): string;
|
|
29
30
|
getSystemEnvironment(): string;
|
|
31
|
+
/** An event line the podman container's CDI watcher logged. */
|
|
32
|
+
podmanEvent(event: string): void;
|
|
30
33
|
stop(): Promise<void>;
|
|
31
34
|
clean(): Promise<void>;
|
|
32
35
|
start(): Promise<void>;
|
|
@@ -14,6 +14,7 @@ import { ExpiryHandler } from './expiry/expiryHandler.js';
|
|
|
14
14
|
import { GridHandler } from './grid/gridHandler.js';
|
|
15
15
|
import { ResourceManager } from './resource/resourceManager.js';
|
|
16
16
|
import { selectContainerOrchestrationProvider } from '../provider/containerOrchestration/selectContainerOrchestration.js';
|
|
17
|
+
import { DockerContainerOrchestration } from '../provider/containerOrchestration/docker/index.js';
|
|
17
18
|
import { RegisterHandler } from './register/index.js';
|
|
18
19
|
import { BalanceHandler } from './balance/balanceHandler.js';
|
|
19
20
|
import { abortControllerSelector, nodeAbortControllerSelector, } from './abort/abortControllerSelector.js';
|
|
@@ -21,6 +22,7 @@ import { pollForRun } from './utils/poll.js';
|
|
|
21
22
|
import { configs } from '../configs/configs.js';
|
|
22
23
|
import { TaskManagerRegistry } from './task/TaskManagerRegistry.js';
|
|
23
24
|
import { StopReasons } from './task/TaskManager.js';
|
|
25
|
+
import { isStaleCdiEvent, PodmanManager, } from '../provider/podmanManager/PodmanManager.js';
|
|
24
26
|
export class BasicNode {
|
|
25
27
|
constructor(options) {
|
|
26
28
|
this.exiting = false;
|
|
@@ -28,8 +30,17 @@ export class BasicNode {
|
|
|
28
30
|
const db = new DB(options.config).db;
|
|
29
31
|
this.repository = new NodeRepository(db);
|
|
30
32
|
this.containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime);
|
|
33
|
+
if (options.managedPodman) {
|
|
34
|
+
this.podmanManager = new PodmanManager(new DockerContainerOrchestration(options.dockerSocket, options.gpu), options.config, this.containerOrchestration);
|
|
35
|
+
}
|
|
31
36
|
this.resourceManager = new ResourceManager(this.containerOrchestration, this.repository);
|
|
32
37
|
this.provider = new Provider(this.containerOrchestration, this.repository, this.resourceManager);
|
|
38
|
+
if (this.podmanManager) {
|
|
39
|
+
this.provider.gpuRuntime = this.podmanManager;
|
|
40
|
+
// the API proxy runs in docker beside the node, where replacing podman
|
|
41
|
+
// cannot reach it
|
|
42
|
+
this.provider.apiOrchestration = this.podmanManager.hostDocker;
|
|
43
|
+
}
|
|
33
44
|
this.apiHandler = new ApiHandler(this.sdk, this.repository, this.provider, options.port);
|
|
34
45
|
this.balanceHandler = new BalanceHandler(this.sdk);
|
|
35
46
|
this.gridHandler = new GridHandler(this.sdk, this.repository);
|
|
@@ -46,6 +57,13 @@ export class BasicNode {
|
|
|
46
57
|
await this.registerHandler.register();
|
|
47
58
|
}
|
|
48
59
|
async healthcheck() {
|
|
60
|
+
/**
|
|
61
|
+
* the podman container, when this node owns it: recreated between jobs,
|
|
62
|
+
* so a driver the host replaced since is injected afresh. Nothing of the
|
|
63
|
+
* node's runs inside it at this point, and the image and resource caches
|
|
64
|
+
* live on volumes that survive it.
|
|
65
|
+
*/
|
|
66
|
+
await this.podmanManager?.recreate('between jobs');
|
|
49
67
|
/**
|
|
50
68
|
* run health check,
|
|
51
69
|
*/
|
|
@@ -76,6 +94,11 @@ export class BasicNode {
|
|
|
76
94
|
getSystemEnvironment() {
|
|
77
95
|
return this.repository.getNodeInfo().system_environment;
|
|
78
96
|
}
|
|
97
|
+
/** An event line the podman container's CDI watcher logged. */
|
|
98
|
+
podmanEvent(event) {
|
|
99
|
+
if (isStaleCdiEvent(event))
|
|
100
|
+
this.podmanManager?.markStale(event);
|
|
101
|
+
}
|
|
79
102
|
async stop() {
|
|
80
103
|
await this.marketHandler.stop();
|
|
81
104
|
await this.runHandler.stop();
|
|
@@ -98,6 +121,11 @@ export class BasicNode {
|
|
|
98
121
|
if (await this.balanceHandler.balance()) {
|
|
99
122
|
await this.balanceHandler.check(true);
|
|
100
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* the podman container, when this node owns it: brought up if it is not
|
|
126
|
+
* running, and recreated if its GPU mounts went stale since the last job
|
|
127
|
+
*/
|
|
128
|
+
await this.podmanManager?.ensure();
|
|
101
129
|
/**
|
|
102
130
|
* get an instance to the container
|
|
103
131
|
*/
|
|
@@ -119,6 +147,10 @@ export class BasicNode {
|
|
|
119
147
|
try {
|
|
120
148
|
let resolvedStatus = StopReasons.COMPLETED;
|
|
121
149
|
const periodicHealthcheck = async () => {
|
|
150
|
+
// Leaves the queue, so the healthcheck that follows recreates podman
|
|
151
|
+
// before the node takes a job it cannot run.
|
|
152
|
+
if (this.podmanManager?.isStale())
|
|
153
|
+
return false;
|
|
122
154
|
const { status, error } = await this.containerOrchestration.healthy();
|
|
123
155
|
if (!status) {
|
|
124
156
|
return false;
|
|
@@ -47,5 +47,13 @@ export declare class TaskManagerRegistry {
|
|
|
47
47
|
/** Jobs with a task manager, which is to say the jobs this node is running. */
|
|
48
48
|
size(): number;
|
|
49
49
|
has(jobId: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Restart every operation that is running, in every job: the runtime under
|
|
52
|
+
* them is being replaced. Operations that finished stay finished and ones
|
|
53
|
+
* that have not started pick up the new runtime on their own; only what is
|
|
54
|
+
* running is aborted now and relaunched, which the provider holds until the
|
|
55
|
+
* runtime answers again.
|
|
56
|
+
*/
|
|
57
|
+
restartRunningOperations(): void;
|
|
50
58
|
stop(): Promise<void>;
|
|
51
59
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OperationProgressStatuses } from './TaskManager.js';
|
|
1
2
|
/**
|
|
2
3
|
* TaskManagerRegistry
|
|
3
4
|
*
|
|
@@ -61,6 +62,27 @@ export class TaskManagerRegistry {
|
|
|
61
62
|
has(jobId) {
|
|
62
63
|
return this.registry.has(jobId);
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Restart every operation that is running, in every job: the runtime under
|
|
67
|
+
* them is being replaced. Operations that finished stay finished and ones
|
|
68
|
+
* that have not started pick up the new runtime on their own; only what is
|
|
69
|
+
* running is aborted now and relaunched, which the provider holds until the
|
|
70
|
+
* runtime answers again.
|
|
71
|
+
*/
|
|
72
|
+
restartRunningOperations() {
|
|
73
|
+
for (const manager of this.registry.values()) {
|
|
74
|
+
const group = manager.getCurrentGroup();
|
|
75
|
+
if (!group)
|
|
76
|
+
continue;
|
|
77
|
+
for (const [opId, status] of Object.entries(manager.getCurrentGroupStatus())) {
|
|
78
|
+
if (status !== OperationProgressStatuses.RUNNING)
|
|
79
|
+
continue;
|
|
80
|
+
manager.restartTaskManagerOperation(group, opId).catch((error) => {
|
|
81
|
+
console.warn(`Could not restart operation ${opId} on the new runtime:`, error);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
64
86
|
async stop() {
|
|
65
87
|
const stopPromises = [];
|
|
66
88
|
for (const [jobId, manager] of this.registry.entries()) {
|
|
@@ -4,17 +4,30 @@ import EventEmitter from 'events';
|
|
|
4
4
|
import { NodeRepository } from '../repository/NodeRepository.js';
|
|
5
5
|
import { ResourceManager } from '../node/resource/resourceManager.js';
|
|
6
6
|
import { ContainerOrchestrationInterface } from './containerOrchestration/interface.js';
|
|
7
|
+
import { type GpuRuntime } from './gpuRuntime.js';
|
|
7
8
|
export declare class Provider {
|
|
8
9
|
containerOrchestration: ContainerOrchestrationInterface;
|
|
9
10
|
private repository;
|
|
10
11
|
private resourceManager;
|
|
11
12
|
private readonly frpcImage;
|
|
12
13
|
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;
|
|
13
26
|
constructor(containerOrchestration: ContainerOrchestrationInterface, repository: NodeRepository, resourceManager: ResourceManager);
|
|
14
27
|
hasTrustedExecutionEnvCapability(): Promise<boolean>;
|
|
15
28
|
stopReverseProxyApi(address: string): Promise<boolean>;
|
|
16
29
|
setUpReverseProxyApi(address: string, port: string): Promise<boolean>;
|
|
17
|
-
taskManagerContainerRunOperation(flow: Flow, op: Operation<'container/run'>, controller: AbortController, emitter: EventEmitter): Promise<void>;
|
|
30
|
+
taskManagerContainerRunOperation(flow: Flow, op: Operation<'container/run'>, controller: AbortController, emitter: EventEmitter, retried?: boolean): Promise<void>;
|
|
18
31
|
generateFrpcContainerConfig(name: string, networks: {
|
|
19
32
|
[p: string]: {};
|
|
20
33
|
}, flow: Flow, proxies: any[], isLoadBalanced: boolean, deploymentHash: string | undefined): {
|
|
@@ -8,6 +8,7 @@ import { promiseTimeoutWrapper } from '../utils/timeoutPromiseWrapper.js';
|
|
|
8
8
|
import { liveAbortSignal } from '../utils/liveAbortSignal.js';
|
|
9
9
|
import { generateProxies, generateUrlSecretObject, } from '../utils/expose-util.js';
|
|
10
10
|
import { ContainerStateManager } from './ContainerStateManager.js';
|
|
11
|
+
import { isStaleGpuRuntimeError } from './gpuRuntime.js';
|
|
11
12
|
export class Provider {
|
|
12
13
|
constructor(containerOrchestration, repository, resourceManager) {
|
|
13
14
|
this.containerOrchestration = containerOrchestration;
|
|
@@ -15,6 +16,7 @@ export class Provider {
|
|
|
15
16
|
this.resourceManager = resourceManager;
|
|
16
17
|
this.frpcImage = configs().frp.containerImage;
|
|
17
18
|
this.proxyStartupAbortController = undefined;
|
|
19
|
+
this.apiOrchestration = containerOrchestration;
|
|
18
20
|
applyLoggingProxyToClass(this);
|
|
19
21
|
}
|
|
20
22
|
async hasTrustedExecutionEnvCapability() {
|
|
@@ -28,13 +30,13 @@ export class Provider {
|
|
|
28
30
|
}
|
|
29
31
|
try {
|
|
30
32
|
// Check if the frpc container exists and stop/delete it
|
|
31
|
-
const doesFrpcExist = await this.
|
|
33
|
+
const doesFrpcExist = await this.apiOrchestration.doesContainerExist(frpc_name);
|
|
32
34
|
if (doesFrpcExist) {
|
|
33
|
-
await this.
|
|
35
|
+
await this.apiOrchestration.stopAndDeleteContainer(frpc_name);
|
|
34
36
|
}
|
|
35
37
|
// check if network then delete
|
|
36
|
-
if (await this.
|
|
37
|
-
await this.
|
|
38
|
+
if (await this.apiOrchestration.hasNetwork(networkName)) {
|
|
39
|
+
await this.apiOrchestration.deleteNetwork(networkName);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
catch (error) {
|
|
@@ -48,21 +50,21 @@ export class Provider {
|
|
|
48
50
|
const nodeName = configs().frp.nodeInternalHostName;
|
|
49
51
|
const networkName = `api-${address}`;
|
|
50
52
|
const frpc_name = `frpc-api-${address}`;
|
|
51
|
-
await this.
|
|
53
|
+
await this.apiOrchestration.createNetwork(networkName);
|
|
52
54
|
const networks = {};
|
|
53
55
|
networks[networkName] = {};
|
|
54
56
|
const startup = new AbortController();
|
|
55
57
|
this.proxyStartupAbortController = startup;
|
|
56
|
-
await this.
|
|
58
|
+
await this.apiOrchestration.pullImage(this.frpcImage, undefined, startup);
|
|
57
59
|
this.resourceManager.images.setImage(this.frpcImage);
|
|
58
|
-
const doesFrpcExist = await this.
|
|
60
|
+
const doesFrpcExist = await this.apiOrchestration.doesContainerExist(frpc_name);
|
|
59
61
|
// Stopped while the image was being pulled, so the proxy is not wanted:
|
|
60
62
|
// creating it now would put back what the caller has taken down.
|
|
61
63
|
if (startup.signal.aborted) {
|
|
62
64
|
throw new Error('API proxy setup was stopped');
|
|
63
65
|
}
|
|
64
66
|
if (!doesFrpcExist) {
|
|
65
|
-
await this.
|
|
67
|
+
await this.apiOrchestration.runFlowContainer(this.frpcImage, {
|
|
66
68
|
name: 'frpc-api-' + address,
|
|
67
69
|
cmd: ['-c', '/etc/frp/frpc.toml'],
|
|
68
70
|
networks,
|
|
@@ -89,10 +91,10 @@ export class Provider {
|
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
else {
|
|
92
|
-
const hasFrpcExited = await this.
|
|
94
|
+
const hasFrpcExited = await this.apiOrchestration.isContainerExited(frpc_name);
|
|
93
95
|
if (hasFrpcExited) {
|
|
94
|
-
await this.
|
|
95
|
-
await this.
|
|
96
|
+
await this.apiOrchestration.stopAndDeleteContainer(frpc_name);
|
|
97
|
+
await this.apiOrchestration.runFlowContainer(this.frpcImage, {
|
|
96
98
|
name: 'frpc-api-' + address,
|
|
97
99
|
cmd: ['-c', '/etc/frp/frpc.toml'],
|
|
98
100
|
networks,
|
|
@@ -126,7 +128,7 @@ export class Provider {
|
|
|
126
128
|
this.proxyStartupAbortController = undefined;
|
|
127
129
|
return true;
|
|
128
130
|
}
|
|
129
|
-
async taskManagerContainerRunOperation(flow, op, controller, emitter) {
|
|
131
|
+
async taskManagerContainerRunOperation(flow, op, controller, emitter, retried = false) {
|
|
130
132
|
let stateManager;
|
|
131
133
|
let exposedPortHealthCheck;
|
|
132
134
|
if (controller.signal.aborted) {
|
|
@@ -134,6 +136,9 @@ export class Provider {
|
|
|
134
136
|
emitter.emit('end');
|
|
135
137
|
return;
|
|
136
138
|
}
|
|
139
|
+
// The runtime may be being replaced under a repair; nothing starts until
|
|
140
|
+
// it answers again.
|
|
141
|
+
await this.gpuRuntime?.ready();
|
|
137
142
|
try {
|
|
138
143
|
emitter.emit('start');
|
|
139
144
|
let frpcContainer;
|
|
@@ -292,8 +297,44 @@ export class Provider {
|
|
|
292
297
|
emitter.emit('exit', { exitCode: info.State.ExitCode });
|
|
293
298
|
}
|
|
294
299
|
catch (error) {
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
const fail = () => {
|
|
301
|
+
emitter.emit('log', error, 'error');
|
|
302
|
+
emitter.emit('error', error);
|
|
303
|
+
};
|
|
304
|
+
const runtime = this.gpuRuntime;
|
|
305
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
306
|
+
// The container could not start because the GPU runtime under it went
|
|
307
|
+
// stale, which the node can fix: podman is recreated and the operation
|
|
308
|
+
// is run again from the top, sidecars included. Once only, and not when
|
|
309
|
+
// the recreate's own restart of this operation (the abort) relaunches it.
|
|
310
|
+
if (!runtime ||
|
|
311
|
+
controller.signal.aborted ||
|
|
312
|
+
!(isStaleGpuRuntimeError(error) || runtime.isStale())) {
|
|
313
|
+
fail();
|
|
314
|
+
}
|
|
315
|
+
else if (retried) {
|
|
316
|
+
// The recreate did not help: a replaced kernel module takes a reboot.
|
|
317
|
+
emitter.emit('log', "The container still cannot start on the GPU runtime after podman was recreated. If the host's NVIDIA driver was replaced, reboot the host.", 'info');
|
|
318
|
+
fail();
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
emitter.emit('log', `Container could not start on the GPU runtime, recreating podman and retrying: ${message}`, 'info');
|
|
322
|
+
try {
|
|
323
|
+
await runtime.recreate(`container start failed on the GPU runtime: ${message}`);
|
|
324
|
+
}
|
|
325
|
+
catch (recreateError) {
|
|
326
|
+
emitter.emit('log', recreateError, 'error');
|
|
327
|
+
emitter.emit('error', recreateError);
|
|
328
|
+
emitter.emit('end');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (controller.signal.aborted) {
|
|
332
|
+
fail();
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
return this.taskManagerContainerRunOperation(flow, op, controller, emitter, true);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
297
338
|
}
|
|
298
339
|
// Clean up resources
|
|
299
340
|
stateManager?.stopMonitoring();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the provider needs of the node's podman manager, when the node
|
|
3
|
+
* manages podman.
|
|
4
|
+
*/
|
|
5
|
+
export interface GpuRuntime {
|
|
6
|
+
/** Resolves once no recreate is in flight: operation starts wait on this. */
|
|
7
|
+
ready(): Promise<void>;
|
|
8
|
+
/** Whether podman's own watcher has found the GPU runtime stale. */
|
|
9
|
+
isStale(): boolean;
|
|
10
|
+
/** Replace the runtime; the operations running on it are restarted around it. */
|
|
11
|
+
recreate(reason: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function isStaleGpuRuntimeError(error: unknown): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a container start fails when the podman container's copy of the host's
|
|
3
|
+
* NVIDIA driver no longer matches the host. Recreating podman is what fixes
|
|
4
|
+
* it, so these are told apart from failures of the container itself.
|
|
5
|
+
*
|
|
6
|
+
* - At create, podman could not resolve or inject the CDI spec:
|
|
7
|
+
* "setting up CDI devices: unresolvable CDI devices nvidia.com/gpu=all".
|
|
8
|
+
* - At start, the OCI runtime could not bind a driver file the spec names,
|
|
9
|
+
* because the host replaced it: "crun: error mounting
|
|
10
|
+
* `/usr/lib/x86_64-linux-gnu/libcuda.so.550.120.04` to rootfs ...: No such
|
|
11
|
+
* file or directory". The file need not carry "nvidia" in its name.
|
|
12
|
+
* - The toolkit's own hook failed: "nvidia-container-cli: mount error".
|
|
13
|
+
*
|
|
14
|
+
* An image that happens to be NVIDIA's ("nvidia/cuda: image not known") is
|
|
15
|
+
* not a match: that is the container's failure, not the runtime's.
|
|
16
|
+
*/
|
|
17
|
+
const STALE_GPU_RUNTIME = [
|
|
18
|
+
/setting up CDI devices|unresolvable CDI devices/i,
|
|
19
|
+
/nvidia-container-(cli|runtime)/i,
|
|
20
|
+
/\b(mount|mounting|stat)\b[^\n]*?(\/dev\/nvidia|libnvidia|libcuda|libnv|nvidia-smi|nvidia-persistenced)/i,
|
|
21
|
+
];
|
|
22
|
+
export function isStaleGpuRuntimeError(error) {
|
|
23
|
+
const message = error instanceof Error
|
|
24
|
+
? error.message
|
|
25
|
+
: String(error?.message ?? error);
|
|
26
|
+
return STALE_GPU_RUNTIME.some((pattern) => pattern.test(message));
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ContainerCreateOptions } from 'dockerode';
|
|
2
|
+
import type { DockerContainerOrchestration } from '../containerOrchestration/docker/index.js';
|
|
3
|
+
import type { ContainerOrchestrationInterface } from '../containerOrchestration/interface.js';
|
|
4
|
+
/** A CDI event line, as the podman container logs them, that asks for a recreate. */
|
|
5
|
+
export declare function isStaleCdiEvent(line: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Owns the podman container the node uses as its provider, over the host's
|
|
8
|
+
* docker socket. The node runs beside podman rather than inside it, so it can
|
|
9
|
+
* recreate podman without taking itself down.
|
|
10
|
+
*
|
|
11
|
+
* The container is a snapshot of the host's NVIDIA driver, taken when it is
|
|
12
|
+
* created; once the host replaces the driver underneath it, GPU containers
|
|
13
|
+
* inside it can no longer start. Recreating the container is the fix, and it
|
|
14
|
+
* is cheap: the image cache and the resource volumes live on docker volumes
|
|
15
|
+
* that survive it. What does not survive is whatever was running inside, so a
|
|
16
|
+
* recreate restarts the running operations around itself, and operation
|
|
17
|
+
* starts wait on `ready()` while it is under way.
|
|
18
|
+
*/
|
|
19
|
+
export declare class PodmanManager {
|
|
20
|
+
/** The host's docker, which also runs the node's API proxy. */
|
|
21
|
+
readonly hostDocker: DockerContainerOrchestration;
|
|
22
|
+
private podman;
|
|
23
|
+
private docker;
|
|
24
|
+
private configDir;
|
|
25
|
+
private staleReason?;
|
|
26
|
+
private recreating?;
|
|
27
|
+
/** What a create needs and nothing changes afterwards, settled once. */
|
|
28
|
+
private prepared?;
|
|
29
|
+
/**
|
|
30
|
+
* Created by this process and not used by any operation since: it already
|
|
31
|
+
* carries the host's current driver, so a recreate would change nothing.
|
|
32
|
+
*/
|
|
33
|
+
private fresh;
|
|
34
|
+
constructor(
|
|
35
|
+
/** The host's docker, which also runs the node's API proxy. */
|
|
36
|
+
hostDocker: DockerContainerOrchestration, configDir: string, podman: ContainerOrchestrationInterface);
|
|
37
|
+
/**
|
|
38
|
+
* Note a fault the podman container's watcher reported. Acted on by the
|
|
39
|
+
* next recreate, and while the node is queued it leaves the queue for one.
|
|
40
|
+
*/
|
|
41
|
+
markStale(reason: string): void;
|
|
42
|
+
isStale(): boolean;
|
|
43
|
+
/** Resolves once no recreate is in flight: operation starts wait on this. */
|
|
44
|
+
ready(): Promise<void>;
|
|
45
|
+
/** The podman container exists, runs, and answers on its socket. */
|
|
46
|
+
ensure(): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Remove the podman container and bring up a fresh one; the volumes stay.
|
|
49
|
+
* Operations running inside are restarted around it: aborted now, while
|
|
50
|
+
* their containers can still be stopped, and relaunched once the new
|
|
51
|
+
* socket answers, which `ready()` holds them for. A recreate already under
|
|
52
|
+
* way is joined rather than repeated, and a container this process created
|
|
53
|
+
* that nothing has used since is left as it is, unless it was marked stale.
|
|
54
|
+
*/
|
|
55
|
+
recreate(reason: string): Promise<void>;
|
|
56
|
+
private doRecreate;
|
|
57
|
+
private ensureRunning;
|
|
58
|
+
private create;
|
|
59
|
+
/** @returns the host path of the node's config dir, for the bind mount. */
|
|
60
|
+
private prepare;
|
|
61
|
+
private waitForSocket;
|
|
62
|
+
private ensureVolume;
|
|
63
|
+
private ensureNetwork;
|
|
64
|
+
/**
|
|
65
|
+
* Where the node's config dir lives on the host, read off this container's
|
|
66
|
+
* own mounts: podman needs it at the host path, and the node only knows its
|
|
67
|
+
* own.
|
|
68
|
+
*/
|
|
69
|
+
private hostConfigDir;
|
|
70
|
+
}
|
|
71
|
+
/** Mirrors the podman container the hosted start script creates. */
|
|
72
|
+
export declare function podmanContainerConfig(image: string, hostConfigDir: string): ContainerCreateOptions;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { configs } from '../../configs/configs.js';
|
|
4
|
+
import { sleep } from '../../utils/utils.js';
|
|
5
|
+
import { TaskManagerRegistry } from '../../node/task/TaskManagerRegistry.js';
|
|
6
|
+
const PODMAN_CONTAINER_NAME = 'podman';
|
|
7
|
+
/**
|
|
8
|
+
* The docker network the node, the podman container and the node's API proxy
|
|
9
|
+
* share. It carries the name the docker provider hard-codes into every
|
|
10
|
+
* container it starts (see createDockerRunOptions), which is how the API
|
|
11
|
+
* proxy lands on it and reaches the node by container name. It is not the
|
|
12
|
+
* NOSANA_GATEWAY podman creates inside itself for the jobs, hence the
|
|
13
|
+
* different subnet.
|
|
14
|
+
*/
|
|
15
|
+
const HOST_NETWORK = 'NOSANA_GATEWAY';
|
|
16
|
+
const CACHE_VOLUME = 'podman-cache';
|
|
17
|
+
const SOCKET_VOLUME = 'podman-socket';
|
|
18
|
+
const SOCKET_WAIT_S = 60;
|
|
19
|
+
const SOCKET_POLL_S = 2;
|
|
20
|
+
/**
|
|
21
|
+
* Events the podman container's CDI watcher writes when a GPU path it needs
|
|
22
|
+
* is gone and cannot be repaired from in there: only recreating the container
|
|
23
|
+
* re-injects the host's current driver.
|
|
24
|
+
*/
|
|
25
|
+
const STALE_CDI_EVENTS = ['unrepairable', 'repair-failed'];
|
|
26
|
+
/** A CDI event line, as the podman container logs them, that asks for a recreate. */
|
|
27
|
+
export function isStaleCdiEvent(line) {
|
|
28
|
+
try {
|
|
29
|
+
return STALE_CDI_EVENTS.includes(JSON.parse(line).event);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Owns the podman container the node uses as its provider, over the host's
|
|
37
|
+
* docker socket. The node runs beside podman rather than inside it, so it can
|
|
38
|
+
* recreate podman without taking itself down.
|
|
39
|
+
*
|
|
40
|
+
* The container is a snapshot of the host's NVIDIA driver, taken when it is
|
|
41
|
+
* created; once the host replaces the driver underneath it, GPU containers
|
|
42
|
+
* inside it can no longer start. Recreating the container is the fix, and it
|
|
43
|
+
* is cheap: the image cache and the resource volumes live on docker volumes
|
|
44
|
+
* that survive it. What does not survive is whatever was running inside, so a
|
|
45
|
+
* recreate restarts the running operations around itself, and operation
|
|
46
|
+
* starts wait on `ready()` while it is under way.
|
|
47
|
+
*/
|
|
48
|
+
export class PodmanManager {
|
|
49
|
+
constructor(
|
|
50
|
+
/** The host's docker, which also runs the node's API proxy. */
|
|
51
|
+
hostDocker, configDir, podman) {
|
|
52
|
+
this.hostDocker = hostDocker;
|
|
53
|
+
this.podman = podman;
|
|
54
|
+
/**
|
|
55
|
+
* Created by this process and not used by any operation since: it already
|
|
56
|
+
* carries the host's current driver, so a recreate would change nothing.
|
|
57
|
+
*/
|
|
58
|
+
this.fresh = false;
|
|
59
|
+
this.docker = hostDocker.docker;
|
|
60
|
+
this.configDir = configDir.replace(/^~/, os.homedir()).replace(/\/+$/, '');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Note a fault the podman container's watcher reported. Acted on by the
|
|
64
|
+
* next recreate, and while the node is queued it leaves the queue for one.
|
|
65
|
+
*/
|
|
66
|
+
markStale(reason) {
|
|
67
|
+
if (this.staleReason)
|
|
68
|
+
return;
|
|
69
|
+
this.staleReason = reason;
|
|
70
|
+
console.warn(chalk.yellow(`The podman container's GPU mounts are stale (${reason}); it is recreated before the next job.`));
|
|
71
|
+
}
|
|
72
|
+
isStale() {
|
|
73
|
+
return this.staleReason !== undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Resolves once no recreate is in flight: operation starts wait on this. */
|
|
76
|
+
ready() {
|
|
77
|
+
this.fresh = false;
|
|
78
|
+
return this.recreating ?? Promise.resolve();
|
|
79
|
+
}
|
|
80
|
+
/** The podman container exists, runs, and answers on its socket. */
|
|
81
|
+
async ensure() {
|
|
82
|
+
await this.ensureRunning();
|
|
83
|
+
await this.waitForSocket();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Remove the podman container and bring up a fresh one; the volumes stay.
|
|
87
|
+
* Operations running inside are restarted around it: aborted now, while
|
|
88
|
+
* their containers can still be stopped, and relaunched once the new
|
|
89
|
+
* socket answers, which `ready()` holds them for. A recreate already under
|
|
90
|
+
* way is joined rather than repeated, and a container this process created
|
|
91
|
+
* that nothing has used since is left as it is, unless it was marked stale.
|
|
92
|
+
*/
|
|
93
|
+
recreate(reason) {
|
|
94
|
+
if (this.recreating)
|
|
95
|
+
return this.recreating;
|
|
96
|
+
if (this.fresh && !this.staleReason)
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
this.recreating = this.doRecreate(reason).finally(() => {
|
|
99
|
+
this.recreating = undefined;
|
|
100
|
+
});
|
|
101
|
+
return this.recreating;
|
|
102
|
+
}
|
|
103
|
+
async doRecreate(reason) {
|
|
104
|
+
console.log(chalk.yellow(`Recreating the podman container: ${reason}`));
|
|
105
|
+
this.staleReason = undefined;
|
|
106
|
+
TaskManagerRegistry.getInstance().restartRunningOperations();
|
|
107
|
+
try {
|
|
108
|
+
await this.docker
|
|
109
|
+
.getContainer(PODMAN_CONTAINER_NAME)
|
|
110
|
+
.remove({ force: true });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.statusCode !== 404)
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
await this.ensureRunning();
|
|
117
|
+
await this.waitForSocket();
|
|
118
|
+
}
|
|
119
|
+
async ensureRunning() {
|
|
120
|
+
const container = this.docker.getContainer(PODMAN_CONTAINER_NAME);
|
|
121
|
+
let info;
|
|
122
|
+
try {
|
|
123
|
+
info = await container.inspect();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.statusCode !== 404)
|
|
127
|
+
throw error;
|
|
128
|
+
return this.create();
|
|
129
|
+
}
|
|
130
|
+
if (!info.State.Running)
|
|
131
|
+
await container.start();
|
|
132
|
+
}
|
|
133
|
+
async create() {
|
|
134
|
+
const image = configs().podman.containerImage;
|
|
135
|
+
this.prepared ??= this.prepare(image).catch((error) => {
|
|
136
|
+
this.prepared = undefined; // so the next create tries again
|
|
137
|
+
throw error;
|
|
138
|
+
});
|
|
139
|
+
const hostConfigDir = await this.prepared;
|
|
140
|
+
console.log(chalk.cyan(`Creating the podman container from ${image}`));
|
|
141
|
+
const container = await this.docker.createContainer(podmanContainerConfig(image, hostConfigDir));
|
|
142
|
+
await container.start();
|
|
143
|
+
this.fresh = true;
|
|
144
|
+
}
|
|
145
|
+
/** @returns the host path of the node's config dir, for the bind mount. */
|
|
146
|
+
async prepare(image) {
|
|
147
|
+
const [hostConfigDir] = await Promise.all([
|
|
148
|
+
this.hostConfigDir(),
|
|
149
|
+
this.ensureVolume(CACHE_VOLUME),
|
|
150
|
+
this.ensureVolume(SOCKET_VOLUME),
|
|
151
|
+
this.ensureNetwork(),
|
|
152
|
+
this.hostDocker.pullImage(image),
|
|
153
|
+
]);
|
|
154
|
+
return hostConfigDir;
|
|
155
|
+
}
|
|
156
|
+
async waitForSocket() {
|
|
157
|
+
const deadline = Date.now() + SOCKET_WAIT_S * 1000;
|
|
158
|
+
while (Date.now() < deadline) {
|
|
159
|
+
if ((await this.podman.healthy()).status)
|
|
160
|
+
return;
|
|
161
|
+
await sleep(SOCKET_POLL_S);
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`The podman container is running but its socket did not answer within ${SOCKET_WAIT_S}s`);
|
|
164
|
+
}
|
|
165
|
+
async ensureVolume(name) {
|
|
166
|
+
if (!(await this.hostDocker.hasVolume(name))) {
|
|
167
|
+
await this.hostDocker.createVolume(name);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async ensureNetwork() {
|
|
171
|
+
if (await this.hostDocker.hasNetwork(HOST_NETWORK))
|
|
172
|
+
return;
|
|
173
|
+
await this.docker.createNetwork({
|
|
174
|
+
Name: HOST_NETWORK,
|
|
175
|
+
Driver: 'bridge',
|
|
176
|
+
IPAM: {
|
|
177
|
+
Driver: 'default',
|
|
178
|
+
Config: [{ Subnet: '192.168.102.0/24', Gateway: '192.168.102.1' }],
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Where the node's config dir lives on the host, read off this container's
|
|
184
|
+
* own mounts: podman needs it at the host path, and the node only knows its
|
|
185
|
+
* own.
|
|
186
|
+
*/
|
|
187
|
+
async hostConfigDir() {
|
|
188
|
+
let mounts = [];
|
|
189
|
+
try {
|
|
190
|
+
({ Mounts: mounts } = await this.docker
|
|
191
|
+
.getContainer(os.hostname())
|
|
192
|
+
.inspect());
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Reported below, with what is needed rather than what failed.
|
|
196
|
+
}
|
|
197
|
+
const mount = mounts.find((m) => m.Destination.replace(/\/+$/, '') === this.configDir);
|
|
198
|
+
if (!mount?.Source) {
|
|
199
|
+
throw new Error(`Cannot manage podman: the node's config dir ${this.configDir} must be bind-mounted from the host, and the node must run in a docker container whose hostname is its id.`);
|
|
200
|
+
}
|
|
201
|
+
return mount.Source;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Mirrors the podman container the hosted start script creates. */
|
|
205
|
+
export function podmanContainerConfig(image, hostConfigDir) {
|
|
206
|
+
return {
|
|
207
|
+
name: PODMAN_CONTAINER_NAME,
|
|
208
|
+
Image: image,
|
|
209
|
+
Cmd: ['unix:/podman/podman.sock'],
|
|
210
|
+
Env: ['ENABLE_GPU=true', 'NVIDIA_DRIVER_CAPABILITIES=all'],
|
|
211
|
+
NetworkingConfig: { EndpointsConfig: { [HOST_NETWORK]: {} } },
|
|
212
|
+
HostConfig: {
|
|
213
|
+
Privileged: true,
|
|
214
|
+
DeviceRequests: [
|
|
215
|
+
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
|
|
216
|
+
],
|
|
217
|
+
Devices: [
|
|
218
|
+
{
|
|
219
|
+
PathOnHost: '/dev/fuse',
|
|
220
|
+
PathInContainer: '/dev/fuse',
|
|
221
|
+
CgroupPermissions: 'rwm',
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
Mounts: [
|
|
225
|
+
{ Type: 'volume', Source: CACHE_VOLUME, Target: '/var/lib/containers' },
|
|
226
|
+
{ Type: 'volume', Source: SOCKET_VOLUME, Target: '/podman' },
|
|
227
|
+
{ Type: 'bind', Source: hostConfigDir, Target: '/root/.nosana' },
|
|
228
|
+
{
|
|
229
|
+
Type: 'bind',
|
|
230
|
+
Source: '/run/nvidia-persistenced',
|
|
231
|
+
Target: '/run/nvidia-persistenced',
|
|
232
|
+
BindOptions: { Propagation: 'rslave' },
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
RestartPolicy: { Name: 'unless-stopped' },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -52,8 +52,10 @@ export const generateProxies = (flowId, op, opIndex, ports, name, operationId, d
|
|
|
52
52
|
for (let exposedPort of ports) {
|
|
53
53
|
// TODO: use ids generated in the taskmanager to make secerts
|
|
54
54
|
const generatedId = generateExposeId(flowId, opIndex, exposedPort.port, op.args.private);
|
|
55
|
+
// When an op exposes more than one port, include the port in the id so each
|
|
56
|
+
// port gets a distinct deployment endpoint instead of colliding on a shared one.
|
|
55
57
|
const generatedDeploymentId = deploymentId
|
|
56
|
-
? generateExposeId(deploymentId, op.id, 0, false)
|
|
58
|
+
? generateExposeId(deploymentId, op.id, ports.length > 1 ? exposedPort.port : 0, false)
|
|
57
59
|
: undefined;
|
|
58
60
|
let proxyHTTPHealthCheckPath = undefined;
|
|
59
61
|
if (exposedPort.health_checks && exposedPort.health_checks.length > 0) {
|
|
@@ -2,9 +2,10 @@ import { DB } from '../../../NodeManager/db/index.js';
|
|
|
2
2
|
import { ResourceManager } from '../../../NodeManager/node/resource/resourceManager.js';
|
|
3
3
|
import { selectContainerOrchestrationProvider } from '../../../NodeManager/provider/containerOrchestration/selectContainerOrchestration.js';
|
|
4
4
|
import { NodeRepository } from '../../../NodeManager/repository/NodeRepository.js';
|
|
5
|
+
import { resolveProvider } from '../sharedOptions/index.js';
|
|
5
6
|
export * from './action.js';
|
|
6
7
|
export async function pruneResources(options) {
|
|
7
|
-
options
|
|
8
|
+
resolveProvider(options);
|
|
8
9
|
try {
|
|
9
10
|
const db = new DB(options.config).db;
|
|
10
11
|
const repository = new NodeRepository(db);
|
|
@@ -13,8 +13,11 @@ import { generateDeploymentEndpointsTable } from './generateDeploymentEndpointsT
|
|
|
13
13
|
import { generateRandomId } from '../../../NodeManager/node/utils/generateRandomId.js';
|
|
14
14
|
import { getSDK } from '../../../NodeManager/sdk/index.js';
|
|
15
15
|
import { createLoggingProxy } from '../../../NodeManager/monitoring/proxy/loggingProxy.js';
|
|
16
|
+
import { PodmanManager } from '../../../NodeManager/provider/podmanManager/PodmanManager.js';
|
|
17
|
+
import { DockerContainerOrchestration } from '../../../NodeManager/provider/containerOrchestration/docker/index.js';
|
|
18
|
+
import { resolveProvider } from '../sharedOptions/index.js';
|
|
16
19
|
export async function runJob(jobDefinitionFile, options) {
|
|
17
|
-
options
|
|
20
|
+
resolveProvider(options);
|
|
18
21
|
try {
|
|
19
22
|
const sdk = getSDK();
|
|
20
23
|
const jobDefinition = await resolveJobDefinition(options, jobDefinitionFile);
|
|
@@ -32,6 +35,16 @@ export async function runJob(jobDefinitionFile, options) {
|
|
|
32
35
|
const containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime);
|
|
33
36
|
const resourceManager = new ResourceManager(containerOrchestration, repository);
|
|
34
37
|
const provider = new Provider(containerOrchestration, repository, resourceManager);
|
|
38
|
+
/**
|
|
39
|
+
* the podman container, when this node owns it: brought up before the job
|
|
40
|
+
* needs it, and repaired under the job should a container fail to start on
|
|
41
|
+
* the GPU runtime
|
|
42
|
+
*/
|
|
43
|
+
if (options.managedPodman) {
|
|
44
|
+
const podmanManager = new PodmanManager(new DockerContainerOrchestration(options.dockerSocket, options.gpu), options.config, containerOrchestration);
|
|
45
|
+
provider.gpuRuntime = podmanManager;
|
|
46
|
+
await podmanManager.ensure();
|
|
47
|
+
}
|
|
35
48
|
const logger = new ConsoleLogger(false);
|
|
36
49
|
logger.addObserver();
|
|
37
50
|
const job = generateRandomId(32);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Argument, Command, Option } from 'commander';
|
|
2
2
|
import { runJob } from './action.js';
|
|
3
|
-
import { gpuOption, verboseOption, walletOption } from '../sharedOptions/index.js';
|
|
3
|
+
import { gpuOption, managedPodmanOption, verboseOption, walletOption, } from '../sharedOptions/index.js';
|
|
4
4
|
export * from './action.js';
|
|
5
5
|
export const runNodeCommand = new Command('run')
|
|
6
6
|
.description('Run Job Definition File')
|
|
@@ -8,6 +8,7 @@ export const runNodeCommand = new Command('run')
|
|
|
8
8
|
.addOption(new Option('--url <url>', 'Url path for the JSON flow'))
|
|
9
9
|
.addOption(new Option('-c, --config <path>', 'Config path (to store the flows database and other config)').default('~/.nosana/'))
|
|
10
10
|
.addOption(new Option('--docker, --podman <URI>', 'Podman/Docker connection URI').default('~/.nosana/podman/podman.sock'))
|
|
11
|
+
.addOption(managedPodmanOption)
|
|
11
12
|
.addOption(walletOption)
|
|
12
13
|
.addOption(gpuOption)
|
|
13
14
|
.addOption(verboseOption)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Table } from 'console-table-printer';
|
|
2
|
-
import { isOperator, isSpreadMarker, } from '@nosana/sdk';
|
|
2
|
+
import { getExposePorts, isOperator, isSpreadMarker, } from '@nosana/sdk';
|
|
3
3
|
import { generateExposeId } from '../../../NodeManager/utils/expose-util.js';
|
|
4
4
|
import { configs } from '../../../NodeManager/configs/configs.js';
|
|
5
5
|
export function generateDeploymentEndpointsTable(jobDefinition) {
|
|
@@ -14,9 +14,12 @@ export function generateDeploymentEndpointsTable(jobDefinition) {
|
|
|
14
14
|
if (op.type === 'container/run') {
|
|
15
15
|
const { expose } = op.args;
|
|
16
16
|
if (expose) {
|
|
17
|
+
// Mirror generateProxies: only single-port ops fall back to 0, so a
|
|
18
|
+
// multi-port op gets a distinct endpoint per port.
|
|
19
|
+
const exposedPortCount = getExposePorts(op).length;
|
|
17
20
|
if (typeof expose === 'number' ||
|
|
18
21
|
(typeof expose === 'string' && !isOperator(expose))) {
|
|
19
|
-
const generatedId = generateExposeId(jobDefinition.deployment_id, op.id, 0, false);
|
|
22
|
+
const generatedId = generateExposeId(jobDefinition.deployment_id, op.id, exposedPortCount > 1 ? expose : 0, false);
|
|
20
23
|
table.addRow({
|
|
21
24
|
OpId: op.id,
|
|
22
25
|
Port: expose,
|
|
@@ -30,7 +33,7 @@ export function generateDeploymentEndpointsTable(jobDefinition) {
|
|
|
30
33
|
if (typeof port === 'string' && isOperator(port))
|
|
31
34
|
return; // skip dynamic
|
|
32
35
|
const p = typeof port === 'object' ? port.port : port;
|
|
33
|
-
const generatedId = generateExposeId(jobDefinition.deployment_id, op.id, 0, false);
|
|
36
|
+
const generatedId = generateExposeId(jobDefinition.deployment_id, op.id, exposedPortCount > 1 ? p : 0, false);
|
|
34
37
|
table.addRow({
|
|
35
38
|
OpId: op.id,
|
|
36
39
|
Port: p,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Where the managed podman answers, inside the node: the podman-socket volume
|
|
4
|
+
* is mounted there, and at /podman in the podman container.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MANAGED_PODMAN_SOCKET = "~/.nosana/podman/podman.sock";
|
|
7
|
+
export declare const managedPodmanOption: Option;
|
|
8
|
+
/**
|
|
9
|
+
* Settles which provider the --docker/--podman URI names. With
|
|
10
|
+
* --managed-podman the URI is the host's docker socket, over which the node
|
|
11
|
+
* creates its podman container; that podman answers on the volume the two
|
|
12
|
+
* share, at the default podman socket path, and runs every job.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveProvider(options: {
|
|
15
|
+
[key: string]: any;
|
|
16
|
+
}): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Option } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* Where the managed podman answers, inside the node: the podman-socket volume
|
|
4
|
+
* is mounted there, and at /podman in the podman container.
|
|
5
|
+
*/
|
|
6
|
+
export const MANAGED_PODMAN_SOCKET = '~/.nosana/podman/podman.sock';
|
|
7
|
+
export const managedPodmanOption = new Option('--managed-podman', 'Run jobs in a podman container the node creates over the docker socket given with --docker, and recreates when its GPU mounts go stale');
|
|
8
|
+
/**
|
|
9
|
+
* Settles which provider the --docker/--podman URI names. With
|
|
10
|
+
* --managed-podman the URI is the host's docker socket, over which the node
|
|
11
|
+
* creates its podman container; that podman answers on the volume the two
|
|
12
|
+
* share, at the default podman socket path, and runs every job.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveProvider(options) {
|
|
15
|
+
if (options.managedPodman) {
|
|
16
|
+
options.provider = 'podman';
|
|
17
|
+
options.dockerSocket = options.podman;
|
|
18
|
+
options.podman = MANAGED_PODMAN_SOCKET;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
options.provider = process.argv.some((arg) => arg === '--docker')
|
|
22
|
+
? 'docker'
|
|
23
|
+
: 'podman';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -2,10 +2,11 @@ import 'rpc-websockets/dist/lib/client.js';
|
|
|
2
2
|
import NodeManager from '../../../NodeManager/index.js';
|
|
3
3
|
import { requestExit } from '../../../exitCodes.js';
|
|
4
4
|
import { validateCLIVersion } from '../../../version/index.js';
|
|
5
|
+
import { resolveProvider } from '../sharedOptions/index.js';
|
|
5
6
|
/** Failures no restart resolves: the node reports them and stops. */
|
|
6
7
|
const TERMINAL_ERRORS = ['NodeBannedError', 'NodeNotQualifiedError'];
|
|
7
8
|
export async function startNode(market, options) {
|
|
8
|
-
options
|
|
9
|
+
resolveProvider(options);
|
|
9
10
|
const nodeManager = new NodeManager(options);
|
|
10
11
|
while (true) {
|
|
11
12
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command, Option } from 'commander';
|
|
2
2
|
import { startNode } from './action.js';
|
|
3
|
-
import { networkOption, portOption, rpcOption, walletOption, verboseOption, gpuOption, teeRuntimeOption, } from '../sharedOptions/index.js';
|
|
3
|
+
import { networkOption, portOption, rpcOption, walletOption, verboseOption, gpuOption, teeRuntimeOption, managedPodmanOption, } from '../sharedOptions/index.js';
|
|
4
4
|
export * from './action.js';
|
|
5
5
|
export const startNodeCommand = new Command('start')
|
|
6
6
|
.description('Start Nosana Node')
|
|
@@ -9,6 +9,7 @@ export const startNodeCommand = new Command('start')
|
|
|
9
9
|
.addOption(rpcOption)
|
|
10
10
|
.addOption(walletOption)
|
|
11
11
|
.addOption(new Option('--docker, --podman <URI>', 'Podman/Docker connection URI').default('~/.nosana/podman/podman.sock'))
|
|
12
|
+
.addOption(managedPodmanOption)
|
|
12
13
|
.addOption(new Option('-c, --config <path>', 'Config path (to store the flows database and other config)').default('~/.nosana/'))
|
|
13
14
|
.addOption(teeRuntimeOption)
|
|
14
15
|
.addOption(gpuOption)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosana/node",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.63-rc",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@nosana/node",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.63-rc",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|