@nosana/node 1.1.66-rc → 1.1.67-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.66-rc",
3
+ "version": "1.1.67-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"
@@ -41,6 +41,26 @@ export type Resources = {
41
41
  volumes: {
42
42
  [key: string]: VolumeResource;
43
43
  };
44
+ containers: {
45
+ [id: string]: ContainerResource;
46
+ };
47
+ };
48
+ /**
49
+ * A container this node started, recorded so a later resync can tear down
50
+ * anything it left behind. Written when the container starts and removed once
51
+ * it is deliberately deleted, so an entry surviving into a resync is a
52
+ * container a crash or restart orphaned.
53
+ *
54
+ * Only job-scoped containers are tracked — op containers, their frpc/bridge
55
+ * sidecars and the resource-download containers. The node's persistent proxies
56
+ * opt out (see `RunContainerArgs.track`), so a resync leaves them running.
57
+ * Tracking by id keeps a node to the containers it owns: a second node on the
58
+ * same host has its own db, and a user's own containers were never recorded.
59
+ */
60
+ export type ContainerResource = {
61
+ /** Container name when one was assigned; resource-download containers have none. */
62
+ name?: string;
63
+ createdAt: Date;
44
64
  };
45
65
  export type ResourceHistory = {
46
66
  lastUsed: Date;
@@ -6,6 +6,7 @@ const initial_state = {
6
6
  resources: {
7
7
  images: {},
8
8
  volumes: {},
9
+ containers: {},
9
10
  },
10
11
  flows: {},
11
12
  info: {
@@ -44,6 +45,11 @@ export class DB {
44
45
  if (!this.db.data.resources) {
45
46
  this.db.data.resources = initial_state.resources;
46
47
  }
48
+ // Added after the other resource maps, so a db written before it exists
49
+ // reaches here without one.
50
+ if (!this.db.data.resources.containers) {
51
+ this.db.data.resources.containers = {};
52
+ }
47
53
  if (!this.db.data.flows) {
48
54
  this.db.data.flows = initial_state.flows;
49
55
  }
@@ -27,7 +27,7 @@ export class BasicNode {
27
27
  this.sdk = getSDK();
28
28
  const db = new DB(options.config).db;
29
29
  this.repository = new NodeRepository(db);
30
- this.containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime);
30
+ this.containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime, this.repository);
31
31
  this.resourceManager = new ResourceManager(this.containerOrchestration, this.repository);
32
32
  this.provider = new Provider(this.containerOrchestration, this.repository, this.resourceManager);
33
33
  this.apiHandler = new ApiHandler(this.sdk, this.repository, this.provider, options.port);
@@ -43,6 +43,18 @@ export declare class ResourceManager {
43
43
  volumes: VolumeManager;
44
44
  constructor(containerOrchestration: ContainerOrchestrationInterface, repository: NodeRepository);
45
45
  resyncResourcesDB(): Promise<void>;
46
+ /**
47
+ * Tears down containers this node started that are still around at resync.
48
+ *
49
+ * A resync runs between jobs with nothing of ours meant to be running, so any
50
+ * tracked container is one a crash or restart orphaned — a dead resource
51
+ * download still holding its volume, or a job container never cleaned up.
52
+ * Each is stopped, removed and untracked. The record is only ever written for
53
+ * job-scoped containers, so the persistent API proxy is left untouched, and
54
+ * because entries are keyed by containers this node created, a co-tenant
55
+ * node's containers and a user's own are never in scope.
56
+ */
57
+ resyncContainersDB(): Promise<void>;
46
58
  fetchMarketRequiredResources(market: Market | string): Promise<void>;
47
59
  prune(): Promise<void>;
48
60
  getResourceVolumes(resources: Resource[], controller: AbortController, job?: JobContext, project?: string): Promise<{
@@ -48,12 +48,40 @@ export class ResourceManager {
48
48
  this.volumes = new VolumeManager(containerOrchestration, repository);
49
49
  }
50
50
  async resyncResourcesDB() {
51
+ await this.resyncContainersDB();
51
52
  await this.images.resyncImagesDB();
52
53
  await this.volumes.resyncResourcesDB();
53
54
  if (this.required_market) {
54
55
  await this.fetchMarketRequiredResources(this.required_market);
55
56
  }
56
57
  }
58
+ /**
59
+ * Tears down containers this node started that are still around at resync.
60
+ *
61
+ * A resync runs between jobs with nothing of ours meant to be running, so any
62
+ * tracked container is one a crash or restart orphaned — a dead resource
63
+ * download still holding its volume, or a job container never cleaned up.
64
+ * Each is stopped, removed and untracked. The record is only ever written for
65
+ * job-scoped containers, so the persistent API proxy is left untouched, and
66
+ * because entries are keyed by containers this node created, a co-tenant
67
+ * node's containers and a user's own are never in scope.
68
+ */
69
+ async resyncContainersDB() {
70
+ for (const [id, { name }] of Object.entries(this.repository.getContainersResources())) {
71
+ try {
72
+ if (await this.containerOrchestration.doesContainerExist(id)) {
73
+ await this.containerOrchestration.stopAndDeleteContainer(id);
74
+ }
75
+ }
76
+ catch (err) {
77
+ // A container that will not go stays tracked for the next resync rather
78
+ // than being forgotten; the volume it holds is reclaimed once it does.
79
+ console.error(`Failed to clean up leftover container ${name ?? id}: ${err.message}`);
80
+ continue;
81
+ }
82
+ this.repository.untrackContainer(id);
83
+ }
84
+ }
57
85
  async fetchMarketRequiredResources(market) {
58
86
  this.required_market = typeof market === 'string' ? market : market.address.toString();
59
87
  try {
@@ -324,8 +324,9 @@ export class VolumeManager {
324
324
  }
325
325
  catch (error) {
326
326
  this.progressBarReporter.stop(`${syncing ? 'Syncing' : 'Downloading'} resource ${name} stopped`);
327
- // Force-removal also stops a download an abort left running.
328
- await container.remove({ force: true }).catch(() => { });
327
+ // Force-removal also stops a download an abort left running, and
328
+ // untracks it so the next resync does not chase a container already gone.
329
+ await this.containerOrchestration.stopAndDeleteContainer(container.id);
329
330
  // The volume goes with the failed download, and its entry goes with the
330
331
  // volume. If the deletion itself fails, the entry must stay: a pending
331
332
  // entry over a leftover volume is resynced or expired later, which is
@@ -408,7 +409,11 @@ export class VolumeManager {
408
409
  this.repository.deleteVolumeResource(resource);
409
410
  }
410
411
  catch (err) {
411
- const message = err.json.message;
412
+ // The entry is kept so the next resync retries. A leftover container
413
+ // holding the volume is the usual cause, which the container resync
414
+ // that runs first now clears; log rather than swallow so a removal
415
+ // that still fails is visible instead of silently leaking disk.
416
+ console.error(`Failed to remove expired volume ${volume}: ${err.message}`);
412
417
  }
413
418
  }
414
419
  }
@@ -68,6 +68,9 @@ export class Provider {
68
68
  if (!doesFrpcExist) {
69
69
  await this.containerOrchestration.runFlowContainer(this.frpcImage, {
70
70
  name: 'frpc-api-' + address,
71
+ // The node's persistent API proxy, not a job container: a resync
72
+ // between jobs must leave it running rather than reap it.
73
+ track: false,
71
74
  cmd: ['-c', '/etc/frp/frpc.toml'],
72
75
  networks,
73
76
  requires_network_mode: true,
@@ -91,6 +94,9 @@ export class Provider {
91
94
  await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
92
95
  await this.containerOrchestration.runFlowContainer(this.frpcImage, {
93
96
  name: 'frpc-api-' + address,
97
+ // The node's persistent API proxy, not a job container: a resync
98
+ // between jobs must leave it running rather than reap it.
99
+ track: false,
94
100
  cmd: ['-c', '/etc/frp/frpc.toml'],
95
101
  networks,
96
102
  requires_network_mode: true,
@@ -1,9 +1,11 @@
1
1
  import { Container, ContainerCreateOptions, ImageInfo, Image, Volume, VolumeCreateResponse, VolumeInspectInfo, ContainerInspectInfo } from 'dockerode';
2
2
  import { DockerAuth } from '@nosana/sdk';
3
+ import type { NodeRepository } from '../../../repository/NodeRepository.js';
3
4
  import { DockerExtended } from './dockerExtended/index.js';
4
5
  import { ContainerOrchestrationInterface, RunContainerArgs } from '../interface.js';
5
6
  import { ReturnedStatus } from '../../types.js';
6
7
  export declare class DockerContainerOrchestration implements ContainerOrchestrationInterface {
8
+ protected repository?: NodeRepository | undefined;
7
9
  docker: DockerExtended;
8
10
  host: string;
9
11
  port: string;
@@ -12,7 +14,7 @@ export declare class DockerContainerOrchestration implements ContainerOrchestrat
12
14
  gpu: string;
13
15
  teeRuntime?: string;
14
16
  listeners: Map<string, (() => Promise<void>)[]>;
15
- constructor(server: string, gpu: string, teeRuntime?: string);
17
+ constructor(server: string, gpu: string, teeRuntime?: string, repository?: NodeRepository | undefined);
16
18
  checkCustomRuntimeAvailability(runtime: string): Promise<boolean>;
17
19
  getContainerByName(name: string): Promise<Container | undefined>;
18
20
  getContainersByName(names: string[]): Promise<Container[]>;
@@ -38,6 +40,14 @@ export declare class DockerContainerOrchestration implements ContainerOrchestrat
38
40
  healthy(): Promise<ReturnedStatus>;
39
41
  getProtocol(): string;
40
42
  setupContainerAbortListener(containerId: string, controller: AbortController): void;
43
+ /**
44
+ * Records a container we started so a resync can reap it if a crash orphans
45
+ * it. Every started container funnels through here, so a new call site is
46
+ * tracked without the caller doing anything; the persistent proxies opt out
47
+ * with `track: false`. Keyed by container id, matching what `inspect().Id`
48
+ * reports at teardown, so the same key untracks it.
49
+ */
50
+ protected recordContainer(id: string, name?: string, track?: boolean): void;
41
51
  runContainer(args: ContainerCreateOptions, controller?: AbortController): Promise<Container>;
42
52
  runFlowContainer(image: string, args: RunContainerArgs): Promise<Container>;
43
53
  stopContainer(id: string, controller?: AbortController): Promise<void>;
@@ -5,7 +5,8 @@ import { checkDeprecationDeadline } from './utils/deadline.js';
5
5
  import { createDockerRunOptions } from './createDockerRunOptions.js';
6
6
  import { liveAbortSignal } from '../../../utils/liveAbortSignal.js';
7
7
  export class DockerContainerOrchestration {
8
- constructor(server, gpu, teeRuntime) {
8
+ constructor(server, gpu, teeRuntime, repository) {
9
+ this.repository = repository;
9
10
  this.name = 'docker';
10
11
  this.gpu = 'all';
11
12
  this.listeners = new Map();
@@ -244,6 +245,18 @@ export class DockerContainerOrchestration {
244
245
  current.push(stopFunction);
245
246
  this.listeners.set(containerId, current);
246
247
  }
248
+ /**
249
+ * Records a container we started so a resync can reap it if a crash orphans
250
+ * it. Every started container funnels through here, so a new call site is
251
+ * tracked without the caller doing anything; the persistent proxies opt out
252
+ * with `track: false`. Keyed by container id, matching what `inspect().Id`
253
+ * reports at teardown, so the same key untracks it.
254
+ */
255
+ recordContainer(id, name, track = true) {
256
+ if (!track)
257
+ return;
258
+ this.repository?.trackContainer(id, { name });
259
+ }
247
260
  async runContainer(args, controller) {
248
261
  try {
249
262
  if (controller?.signal.aborted) {
@@ -253,6 +266,7 @@ export class DockerContainerOrchestration {
253
266
  ...args,
254
267
  abortSignal: liveAbortSignal(controller?.signal),
255
268
  });
269
+ this.recordContainer(container.id, args.name);
256
270
  await container.start();
257
271
  if (controller) {
258
272
  this.setupContainerAbortListener(container.id, controller);
@@ -271,6 +285,7 @@ export class DockerContainerOrchestration {
271
285
  const container = await this.docker.createContainer({
272
286
  ...createDockerRunOptions(image, args, this.gpu),
273
287
  });
288
+ this.recordContainer(container.id, args.name, args.track);
274
289
  try {
275
290
  if (args.beforeStart)
276
291
  await args.beforeStart(container);
@@ -321,9 +336,20 @@ export class DockerContainerOrchestration {
321
336
  v: true,
322
337
  abortSignal: liveAbortSignal(controller?.signal),
323
338
  });
339
+ // Removed, so forget it. Keyed by the resolved id, matching what
340
+ // `recordContainer` wrote.
341
+ this.repository?.untrackContainer(info.Id);
324
342
  return info;
325
343
  }
326
344
  catch (error) {
345
+ // A 404 means the container is already gone — Docker auto-removed it on
346
+ // exit, say, or a prior teardown already took it — so it is removed as
347
+ // far as the caller is concerned and its tracking entry must not linger.
348
+ // Any other failure leaves the container in place, so it stays tracked
349
+ // for the next resync to retry rather than being forgotten while alive.
350
+ if (error.statusCode === 404) {
351
+ this.repository?.untrackContainer(id);
352
+ }
327
353
  return undefined;
328
354
  }
329
355
  }
@@ -25,6 +25,12 @@ export type RunContainerArgs = {
25
25
  aliases?: string[] | undefined;
26
26
  runtime?: string;
27
27
  bind_network_to_container?: string;
28
+ /**
29
+ * Whether a resync may reap this container. Job containers leave it unset and
30
+ * are tracked; the node's persistent proxies pass `false` so they survive a
31
+ * resync that runs between jobs.
32
+ */
33
+ track?: boolean;
28
34
  security?: {
29
35
  capDrop?: string[];
30
36
  pidsLimit?: number;
@@ -1,11 +1,12 @@
1
1
  import { Container } from 'dockerode';
2
+ import type { NodeRepository } from '../../../repository/NodeRepository.js';
2
3
  import { DockerContainerOrchestration } from '../docker/index.js';
3
4
  import { RunContainerArgs } from '../interface.js';
4
5
  import { RequestInit, Response } from 'undici';
5
6
  export declare class PodmanContainerOrchestration extends DockerContainerOrchestration {
6
7
  private api;
7
8
  name: string;
8
- constructor(server: string, gpu: string, teeRuntime?: string);
9
+ constructor(server: string, gpu: string, teeRuntime?: string, repository?: NodeRepository);
9
10
  libPodAPICall(path: string, options: RequestInit): Promise<Response>;
10
11
  createNetwork(_name: string, controller?: AbortController): Promise<void>;
11
12
  runFlowContainer(image: string, args: RunContainerArgs): Promise<Container>;
@@ -3,8 +3,8 @@ import { createPodmanRunOptions } from './utils/createPodmanRunOptions.js';
3
3
  import { fetch, Agent } from 'undici';
4
4
  import { liveAbortSignal } from '../../../utils/liveAbortSignal.js';
5
5
  export class PodmanContainerOrchestration extends DockerContainerOrchestration {
6
- constructor(server, gpu, teeRuntime) {
7
- super(server, gpu, teeRuntime);
6
+ constructor(server, gpu, teeRuntime, repository) {
7
+ super(server, gpu, teeRuntime, repository);
8
8
  this.name = 'podman';
9
9
  if (this.protocol === 'socket') {
10
10
  this.api = `http://localhost/v4.5.0/libpod`;
@@ -60,6 +60,7 @@ export class PodmanContainerOrchestration extends DockerContainerOrchestration {
60
60
  if (create.status === 201) {
61
61
  const createResult = await create.json();
62
62
  const container = this.docker.getContainer(createResult.Id);
63
+ this.recordContainer(container.id, args.name, args.track);
63
64
  try {
64
65
  if (args.beforeStart)
65
66
  await args.beforeStart(container);
@@ -1,2 +1,3 @@
1
+ import type { NodeRepository } from '../../repository/NodeRepository.js';
1
2
  import type { ContainerOrchestrationInterface } from './interface.js';
2
- export declare function selectContainerOrchestrationProvider(provider: string, url: string, gpu: string, teeRuntime: string | undefined): ContainerOrchestrationInterface;
3
+ export declare function selectContainerOrchestrationProvider(provider: string, url: string, gpu: string, teeRuntime: string | undefined, repository: NodeRepository): ContainerOrchestrationInterface;
@@ -1,14 +1,14 @@
1
1
  import { DockerContainerOrchestration } from './docker/index.js';
2
2
  import { PodmanContainerOrchestration } from './podman/index.js';
3
- export function selectContainerOrchestrationProvider(provider, url, gpu, teeRuntime) {
3
+ export function selectContainerOrchestrationProvider(provider, url, gpu, teeRuntime, repository) {
4
4
  if (provider !== 'docker' && teeRuntime === 'SEV-SNP') {
5
5
  throw new Error('Custom runtimes are only supported with the Docker provider.');
6
6
  }
7
7
  switch (provider) {
8
8
  case 'podman':
9
- return new PodmanContainerOrchestration(url, gpu, teeRuntime);
9
+ return new PodmanContainerOrchestration(url, gpu, teeRuntime, repository);
10
10
  case 'docker':
11
11
  default:
12
- return new DockerContainerOrchestration(url, gpu, teeRuntime);
12
+ return new DockerContainerOrchestration(url, gpu, teeRuntime, repository);
13
13
  }
14
14
  }
@@ -1,6 +1,6 @@
1
1
  import type { LowSync } from 'lowdb';
2
2
  import type { Flow, OpState, FlowState } from '@nosana/sdk';
3
- import type { NodeDb, ResourceHistory, SshKeyGrant, VolumeResource } from '../db/index.js';
3
+ import type { ContainerResource, NodeDb, ResourceHistory, SshKeyGrant, VolumeResource } from '../db/index.js';
4
4
  export declare class NodeRepository {
5
5
  private db;
6
6
  constructor(db: LowSync<NodeDb>);
@@ -67,6 +67,16 @@ export declare class NodeRepository {
67
67
  createVolumeResource(volume: string, fields: VolumeResource): void;
68
68
  updateVolumeResource(volume: string, updatedFields: Partial<VolumeResource> | VolumeResource): void;
69
69
  deleteVolumeResource(volume: string): void;
70
+ getContainersResources(): {
71
+ [id: string]: ContainerResource;
72
+ };
73
+ /**
74
+ * Record a container this node started, so a later resync can remove it if a
75
+ * crash leaves it behind. Keyed by the runtime's container id.
76
+ */
77
+ trackContainer(id: string, fields: Omit<ContainerResource, 'createdAt'>): void;
78
+ /** Drop a container's record once it has been deliberately removed. */
79
+ untrackContainer(id: string): void;
70
80
  displayLog(log: string): string;
71
81
  getFlowOperationName(id: string, index: number): string;
72
82
  }
@@ -260,6 +260,24 @@ export class NodeRepository {
260
260
  delete this.db.data.resources.volumes[volume];
261
261
  this.persist();
262
262
  }
263
+ getContainersResources() {
264
+ return this.db.data.resources.containers;
265
+ }
266
+ /**
267
+ * Record a container this node started, so a later resync can remove it if a
268
+ * crash leaves it behind. Keyed by the runtime's container id.
269
+ */
270
+ trackContainer(id, fields) {
271
+ this.db.data.resources.containers[id] = { ...fields, createdAt: new Date() };
272
+ this.persist();
273
+ }
274
+ /** Drop a container's record once it has been deliberately removed. */
275
+ untrackContainer(id) {
276
+ if (!this.db.data.resources.containers[id])
277
+ return;
278
+ delete this.db.data.resources.containers[id];
279
+ this.persist();
280
+ }
263
281
  displayLog(log) {
264
282
  return log;
265
283
  }
@@ -8,7 +8,7 @@ export async function pruneResources(options) {
8
8
  try {
9
9
  const db = new DB(options.config).db;
10
10
  const repository = new NodeRepository(db);
11
- const containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, undefined);
11
+ const containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, undefined, repository);
12
12
  const resourceManager = new ResourceManager(containerOrchestration, repository);
13
13
  await resourceManager.prune();
14
14
  console.log('Finished pruning system');
@@ -29,7 +29,7 @@ export async function runJob(jobDefinitionFile, options) {
29
29
  log();
30
30
  const db = new DB(options.config).db;
31
31
  const repository = createLoggingProxy(new NodeRepository(db));
32
- const containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime);
32
+ const containerOrchestration = selectContainerOrchestrationProvider(options.provider, options.podman, options.gpu, options.trustedExecutionRuntime, repository);
33
33
  const resourceManager = new ResourceManager(containerOrchestration, repository);
34
34
  const provider = new Provider(containerOrchestration, repository, resourceManager);
35
35
  const logger = new ConsoleLogger(false);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.66-rc",
3
+ "version": "1.1.67-rc",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@nosana/node",
9
- "version": "1.1.66-rc",
9
+ "version": "1.1.67-rc",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@coral-xyz/anchor": "^0.28.1-beta.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.66-rc",
3
+ "version": "1.1.67-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"