@nosana/node 1.1.63-rc → 1.1.64-rc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +0 -1
  2. package/dist/.env +2 -4
  3. package/dist/.env.dev +1 -1
  4. package/dist/.env.prd +1 -1
  5. package/dist/package.json +3 -2
  6. package/dist/src/NodeManager/configs/NodeConfigs.d.ts +1 -0
  7. package/dist/src/NodeManager/configs/NodeConfigs.js +4 -1
  8. package/dist/src/NodeManager/configs/configs.d.ts +0 -3
  9. package/dist/src/NodeManager/configs/configs.js +2 -5
  10. package/dist/src/NodeManager/db/index.d.ts +23 -0
  11. package/dist/src/NodeManager/index.js +9 -4
  12. package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.d.ts +2 -3
  13. package/dist/src/NodeManager/monitoring/reportPodmanDiagnostics.js +8 -12
  14. package/dist/src/NodeManager/node/Node.d.ts +0 -3
  15. package/dist/src/NodeManager/node/Node.js +0 -32
  16. package/dist/src/NodeManager/node/api/ApiHandler.js +16 -3
  17. package/dist/src/NodeManager/node/api/middlewares/index.d.ts +0 -2
  18. package/dist/src/NodeManager/node/api/middlewares/index.js +0 -2
  19. package/dist/src/NodeManager/node/api/middlewares/rest/verifyJobOwnerSignatureMiddleware.js +1 -0
  20. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSJobOwnerSignatureMiddleware.js +1 -0
  21. package/dist/src/NodeManager/node/api/routes/get/node-info.js +9 -7
  22. package/dist/src/NodeManager/node/api/routes/post/index.d.ts +1 -0
  23. package/dist/src/NodeManager/node/api/routes/post/index.js +1 -0
  24. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.d.ts +17 -0
  25. package/dist/src/NodeManager/node/api/routes/post/ssh-authorize.js +53 -0
  26. package/dist/src/NodeManager/node/api/routes/wss/index.d.ts +2 -0
  27. package/dist/src/NodeManager/node/api/routes/wss/index.js +2 -0
  28. package/dist/src/NodeManager/node/api/routes/wss/log.js +2 -1
  29. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.d.ts +21 -0
  30. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminal.js +165 -0
  31. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.d.ts +18 -0
  32. package/dist/src/NodeManager/node/api/routes/wss/terminal/terminalAuthorization.js +61 -0
  33. package/dist/src/NodeManager/node/market/hostManager.d.ts +3 -3
  34. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.d.ts +9 -0
  35. package/dist/src/NodeManager/node/resource/helpers/isAuthGatedResource.js +11 -0
  36. package/dist/src/NodeManager/node/resource/image/imageManager.d.ts +14 -1
  37. package/dist/src/NodeManager/node/resource/image/imageManager.js +30 -4
  38. package/dist/src/NodeManager/node/resource/resourceManager.d.ts +1 -1
  39. package/dist/src/NodeManager/node/resource/resourceManager.js +21 -5
  40. package/dist/src/NodeManager/node/resource/volume/volumeManager.d.ts +31 -4
  41. package/dist/src/NodeManager/node/resource/volume/volumeManager.js +69 -12
  42. package/dist/src/NodeManager/node/task/TaskManagerRegistry.d.ts +0 -8
  43. package/dist/src/NodeManager/node/task/TaskManagerRegistry.js +0 -22
  44. package/dist/src/NodeManager/provider/Provider.d.ts +30 -14
  45. package/dist/src/NodeManager/provider/Provider.js +157 -116
  46. package/dist/src/NodeManager/provider/SshAccessManager.d.ts +49 -0
  47. package/dist/src/NodeManager/provider/SshAccessManager.js +148 -0
  48. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.d.ts +1 -1
  49. package/dist/src/NodeManager/provider/containerOrchestration/docker/createDockerRunOptions.js +6 -3
  50. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.d.ts +5 -1
  51. package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +40 -2
  52. package/dist/src/NodeManager/provider/containerOrchestration/interface.d.ts +28 -1
  53. package/dist/src/NodeManager/provider/containerOrchestration/labels.d.ts +10 -0
  54. package/dist/src/NodeManager/provider/containerOrchestration/labels.js +12 -0
  55. package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +14 -9
  56. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.d.ts +39 -0
  57. package/dist/src/NodeManager/provider/containerOrchestration/podman/utils/createPodmanRunOptions.js +16 -2
  58. package/dist/src/NodeManager/provider/sshSidecar.d.ts +66 -0
  59. package/dist/src/NodeManager/provider/sshSidecar.js +176 -0
  60. package/dist/src/NodeManager/provider/webTerminal.d.ts +24 -0
  61. package/dist/src/NodeManager/provider/webTerminal.js +98 -0
  62. package/dist/src/NodeManager/repository/NodeRepository.d.ts +5 -1
  63. package/dist/src/NodeManager/repository/NodeRepository.js +20 -0
  64. package/dist/src/cli/commands/prune/action.js +1 -2
  65. package/dist/src/cli/commands/run/action.js +1 -14
  66. package/dist/src/cli/commands/run/command.js +1 -2
  67. package/dist/src/cli/commands/sharedOptions/index.d.ts +0 -1
  68. package/dist/src/cli/commands/sharedOptions/index.js +0 -1
  69. package/dist/src/cli/commands/start/action.js +1 -2
  70. package/dist/src/cli/commands/start/command.js +1 -2
  71. package/npm-shrinkwrap.json +28 -2
  72. package/package.json +3 -2
  73. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.d.ts +0 -3
  74. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSMiddleware.js +0 -20
  75. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.d.ts +0 -3
  76. package/dist/src/NodeManager/node/api/middlewares/ws/verifyWSNodeOrJobOwnerSignatureMiddleware.js +0 -33
  77. package/dist/src/NodeManager/provider/gpuRuntime.d.ts +0 -13
  78. package/dist/src/NodeManager/provider/gpuRuntime.js +0 -27
  79. package/dist/src/NodeManager/provider/podmanManager/PodmanManager.d.ts +0 -72
  80. package/dist/src/NodeManager/provider/podmanManager/PodmanManager.js +0 -238
  81. package/dist/src/cli/commands/sharedOptions/--managed-podman.d.ts +0 -16
  82. package/dist/src/cli/commands/sharedOptions/--managed-podman.js +0 -25
@@ -10,7 +10,7 @@ function parseRestartPolicy(restart_policy) {
10
10
  }
11
11
  return undefined;
12
12
  }
13
- export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container }, gpuOption) {
13
+ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, work_dir, entrypoint, aliases, restart_policy, runtime, bind_network_to_container, labels, security, }, gpuOption) {
14
14
  const devices = [];
15
15
  if (gpu) {
16
16
  devices.push({
@@ -37,6 +37,7 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
37
37
  Image: image,
38
38
  WorkingDir: work_dir,
39
39
  Entrypoint: entrypoint,
40
+ Labels: labels,
40
41
  ...(!bind_network_to_container ? {
41
42
  NetworkingConfig: {
42
43
  EndpointsConfig: {
@@ -51,10 +52,10 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
51
52
  'host.containers.internal:8.8.8.8',
52
53
  ]
53
54
  } : {}),
54
- Mounts: volumes?.map(({ dest, name, readonly }) => ({
55
+ Mounts: volumes?.map(({ dest, name, readonly, type }) => ({
55
56
  Target: dest,
56
57
  Source: name,
57
- Type: 'volume',
58
+ Type: type ?? 'volume',
58
59
  ReadOnly: readonly || false,
59
60
  })),
60
61
  NetworkMode: bind_network_to_container ? bind_network_to_container : 'bridge',
@@ -70,6 +71,8 @@ export function createDockerRunOptions(image, { name, cmd, gpu, volumes, env, wo
70
71
  } : {}),
71
72
  RestartPolicy: parseRestartPolicy(restart_policy),
72
73
  ...(runtime === 'SEV-SNP' ? { Runtime: runtime } : {}),
74
+ ...(security?.capDrop ? { CapDrop: security.capDrop } : {}),
75
+ ...(security?.pidsLimit ? { PidsLimit: security.pidsLimit } : {}),
73
76
  },
74
77
  };
75
78
  }
@@ -16,8 +16,12 @@ export declare class DockerContainerOrchestration implements ContainerOrchestrat
16
16
  checkCustomRuntimeAvailability(runtime: string): Promise<boolean>;
17
17
  getContainerByName(name: string): Promise<Container | undefined>;
18
18
  getContainersByName(names: string[]): Promise<Container[]>;
19
+ getContainersByLabels(labels: string[], options?: {
20
+ all?: boolean;
21
+ }): Promise<Container[]>;
22
+ resolveHostPath(path: string): Promise<string>;
19
23
  getConnection(): DockerExtended;
20
- pullImage(image: string, authorisation?: DockerAuth, controller?: AbortController): Promise<void>;
24
+ pullImage(image: string, authorisation?: DockerAuth, controller?: AbortController, force?: boolean): Promise<void>;
21
25
  hasImage(image: string): Promise<boolean>;
22
26
  getImage(image: string): Promise<Image>;
23
27
  listImages(): Promise<ImageInfo[]>;
@@ -73,14 +73,50 @@ export class DockerContainerOrchestration {
73
73
  throw new Error('could not get containers');
74
74
  }
75
75
  }
76
+ async getContainersByLabels(labels, options = {}) {
77
+ try {
78
+ const containers = await this.docker.listContainers({
79
+ all: options.all ?? false,
80
+ filters: {
81
+ label: labels,
82
+ ...(options.all ? {} : { status: ['running'] }),
83
+ },
84
+ });
85
+ return containers.map((info) => this.docker.getContainer(info.Id));
86
+ }
87
+ catch (_) {
88
+ throw new Error('could not get containers');
89
+ }
90
+ }
91
+ async resolveHostPath(path) {
92
+ try {
93
+ // A container's hostname is its id unless overridden, so this finds the
94
+ // node's own container; on a bare host the lookup fails and the path is
95
+ // already a host path.
96
+ const self = await this.docker.getContainer(os.hostname()).inspect();
97
+ const mount = self.Mounts?.find((m) => path === m.Destination || path.startsWith(`${m.Destination}/`));
98
+ if (!mount?.Source)
99
+ return path;
100
+ return mount.Source + path.slice(mount.Destination.length);
101
+ }
102
+ catch (_) {
103
+ return path;
104
+ }
105
+ }
76
106
  getConnection() {
77
107
  return this.docker;
78
108
  }
79
- async pullImage(image, authorisation, controller) {
109
+ async pullImage(image, authorisation, controller, force = false) {
80
110
  try {
81
111
  if (controller?.signal.aborted)
82
112
  throw controller.signal.reason;
83
- if (await this.docker.hasImage(image))
113
+ // A cached image is reused unless the caller forces a pull. `force` is set
114
+ // for a private image a different project owns: the pull then re-verifies
115
+ // this project's credentials against the registry (reusing cached layers)
116
+ // and fails a job that cannot authenticate, rather than handing it a copy
117
+ // another project pulled. The owning project and public images reuse the
118
+ // cache with no registry round-trip, so a cached node stays usable offline.
119
+ if (!force && (await this.docker.hasImage(image)))
84
120
  return;
85
121
  await this.docker.promisePull(image, controller ?? new AbortController(), authorisation);
86
122
  }
@@ -236,6 +272,8 @@ export class DockerContainerOrchestration {
236
272
  ...createDockerRunOptions(image, args, this.gpu),
237
273
  });
238
274
  try {
275
+ if (args.beforeStart)
276
+ await args.beforeStart(container);
239
277
  await container.start();
240
278
  }
241
279
  catch (error) {
@@ -13,7 +13,9 @@ export type RunContainerArgs = {
13
13
  dest: string;
14
14
  name: string;
15
15
  readonly?: boolean;
16
+ type?: 'volume' | 'bind';
16
17
  }>;
18
+ labels?: Record<string, string>;
17
19
  env?: {
18
20
  [key: string]: string;
19
21
  };
@@ -23,11 +25,23 @@ export type RunContainerArgs = {
23
25
  aliases?: string[] | undefined;
24
26
  runtime?: string;
25
27
  bind_network_to_container?: string;
28
+ security?: {
29
+ capDrop?: string[];
30
+ pidsLimit?: number;
31
+ };
32
+ /**
33
+ * Runs between create and start with the container handle, so files can be
34
+ * put in place before the entrypoint runs. A failure removes the created
35
+ * container and fails the run.
36
+ */
37
+ beforeStart?: (container: Container) => Promise<void>;
26
38
  };
27
39
  export interface ContainerOrchestrationInterface {
28
40
  teeRuntime?: string;
41
+ name: string;
42
+ host: string;
29
43
  getConnection(): any;
30
- pullImage(image: string, auth?: DockerAuth, controller?: AbortController): Promise<void>;
44
+ pullImage(image: string, auth?: DockerAuth, controller?: AbortController, force?: boolean): Promise<void>;
31
45
  hasImage(image: string): Promise<boolean>;
32
46
  getImage(image: string): Promise<Image>;
33
47
  listImages(): Promise<ImageInfo[]>;
@@ -43,6 +57,19 @@ export interface ContainerOrchestrationInterface {
43
57
  deleteVolume(name: string, controller?: AbortController): Promise<void>;
44
58
  getContainerByName(name: string): Promise<Container | undefined>;
45
59
  getContainersByName(names: string[]): Promise<Container[]>;
60
+ /**
61
+ * Containers matching every filter, each `key=value` or a bare `key` that
62
+ * only has to be present. Running ones unless `all` is set.
63
+ */
64
+ getContainersByLabels(labels: string[], options?: {
65
+ all?: boolean;
66
+ }): Promise<Container[]>;
67
+ /**
68
+ * The host-side path behind a path the node sees. The node may run in a
69
+ * container with the runtime socket mounted elsewhere, and the daemon
70
+ * resolves bind sources on the host.
71
+ */
72
+ resolveHostPath(path: string): Promise<string>;
46
73
  runContainer(args: ContainerCreateOptions, controller?: AbortController): Promise<Container>;
47
74
  runFlowContainer(image: string, args: RunContainerArgs): Promise<Container>;
48
75
  stopContainer(id: string, controller?: AbortController): Promise<void>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Labels the node stamps on the containers it runs, so they can be found
3
+ * again through the runtime instead of by name.
4
+ */
5
+ export declare const JOB_LABEL = "com.nosana.job";
6
+ export declare const OP_LABEL = "com.nosana.op";
7
+ export declare const OP_INDEX_LABEL = "com.nosana.opIndex";
8
+ export declare const SERVICE_LABEL = "com.nosana.service";
9
+ /** Job and operation ids become container names and label values. */
10
+ export declare function isSafeIdentifier(value: string): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Labels the node stamps on the containers it runs, so they can be found
3
+ * again through the runtime instead of by name.
4
+ */
5
+ export const JOB_LABEL = 'com.nosana.job';
6
+ export const OP_LABEL = 'com.nosana.op';
7
+ export const OP_INDEX_LABEL = 'com.nosana.opIndex';
8
+ export const SERVICE_LABEL = 'com.nosana.service';
9
+ /** Job and operation ids become container names and label values. */
10
+ export function isSafeIdentifier(value) {
11
+ return /^[A-Za-z0-9._:-]+$/.test(value);
12
+ }
@@ -59,20 +59,25 @@ export class PodmanContainerOrchestration extends DockerContainerOrchestration {
59
59
  });
60
60
  if (create.status === 201) {
61
61
  const createResult = await create.json();
62
- const start = await this.libPodAPICall(`/containers/${createResult.Id}/start`, {
63
- method: 'POST',
64
- });
65
- if (start.status === 204) {
66
- const container = this.docker.getContainer(createResult.Id);
67
- return container;
62
+ const container = this.docker.getContainer(createResult.Id);
63
+ try {
64
+ if (args.beforeStart)
65
+ await args.beforeStart(container);
66
+ const start = await this.libPodAPICall(`/containers/${createResult.Id}/start`, {
67
+ method: 'POST',
68
+ });
69
+ if (start.status !== 204) {
70
+ const message = (await start.json()).message;
71
+ throw new Error('Cannot start container: ' + message);
72
+ }
68
73
  }
69
- else {
70
- const message = (await start.json()).message;
74
+ catch (error) {
71
75
  // A created container holds its name whether it ran or not, and
72
76
  // nothing else can take that name until it is gone.
73
77
  await this.stopAndDeleteContainer(createResult.Id);
74
- throw new Error('Cannot start container: ' + message);
78
+ throw error;
75
79
  }
80
+ return container;
76
81
  }
77
82
  error = await create.json();
78
83
  if (error.message !== `${image}: image not known`)
@@ -7,6 +7,12 @@ import type { RunContainerArgs } from '../../interface.js';
7
7
  * @returns
8
8
  */
9
9
  export declare function createPodmanRunOptions(image: string, args: RunContainerArgs, gpuOption: string): {
10
+ resource_limits?: {
11
+ pids: {
12
+ limit: number;
13
+ };
14
+ } | undefined;
15
+ cap_drop?: string[] | undefined;
10
16
  hostadd: string[];
11
17
  netns: {
12
18
  nsmode: string;
@@ -32,12 +38,25 @@ export declare function createPodmanRunOptions(image: string, args: RunContainer
32
38
  image: string;
33
39
  name: string | undefined;
34
40
  command: string[] | undefined;
41
+ labels: Record<string, string> | undefined;
35
42
  volumes: {
36
43
  dest: string;
37
44
  name: string;
38
45
  Options: string[];
39
46
  }[] | undefined;
47
+ mounts: {
48
+ type: string;
49
+ source: string;
50
+ destination: string;
51
+ options: string[];
52
+ }[] | undefined;
40
53
  } | {
54
+ resource_limits?: {
55
+ pids: {
56
+ limit: number;
57
+ };
58
+ } | undefined;
59
+ cap_drop?: string[] | undefined;
41
60
  hostadd: string[];
42
61
  netns: {
43
62
  nsmode: string;
@@ -64,12 +83,25 @@ export declare function createPodmanRunOptions(image: string, args: RunContainer
64
83
  image: string;
65
84
  name: string | undefined;
66
85
  command: string[] | undefined;
86
+ labels: Record<string, string> | undefined;
67
87
  volumes: {
68
88
  dest: string;
69
89
  name: string;
70
90
  Options: string[];
71
91
  }[] | undefined;
92
+ mounts: {
93
+ type: string;
94
+ source: string;
95
+ destination: string;
96
+ options: string[];
97
+ }[] | undefined;
72
98
  } | {
99
+ resource_limits?: {
100
+ pids: {
101
+ limit: number;
102
+ };
103
+ } | undefined;
104
+ cap_drop?: string[] | undefined;
73
105
  hostadd: string[];
74
106
  netns: {
75
107
  nsmode: string;
@@ -95,9 +127,16 @@ export declare function createPodmanRunOptions(image: string, args: RunContainer
95
127
  image: string;
96
128
  name: string | undefined;
97
129
  command: string[] | undefined;
130
+ labels: Record<string, string> | undefined;
98
131
  volumes: {
99
132
  dest: string;
100
133
  name: string;
101
134
  Options: string[];
102
135
  }[] | undefined;
136
+ mounts: {
137
+ type: string;
138
+ source: string;
139
+ destination: string;
140
+ options: string[];
141
+ }[] | undefined;
103
142
  };
@@ -24,7 +24,7 @@ function parseRestartPolicy(restart_policy) {
24
24
  * @returns
25
25
  */
26
26
  export function createPodmanRunOptions(image, args, gpuOption) {
27
- const { name, cmd, gpu, volumes, env, aliases, work_dir, entrypoint, restart_policy, runtime, } = args;
27
+ const { name, cmd, gpu, volumes, env, aliases, work_dir, entrypoint, restart_policy, labels, security, runtime, } = args;
28
28
  const devices = gpu
29
29
  ? gpuOption === 'all'
30
30
  ? [
@@ -41,13 +41,22 @@ export function createPodmanRunOptions(image, args, gpuOption) {
41
41
  image,
42
42
  name,
43
43
  command: cmd,
44
- volumes: volumes?.map((v) => {
44
+ labels,
45
+ volumes: volumes?.filter((v) => v.type !== 'bind').map((v) => {
45
46
  return {
46
47
  dest: v.dest,
47
48
  name: v.name,
48
49
  Options: v.readonly ? ['ro'] : [],
49
50
  };
50
51
  }),
52
+ mounts: volumes?.filter((v) => v.type === 'bind').map((v) => {
53
+ return {
54
+ type: 'bind',
55
+ source: v.name,
56
+ destination: v.dest,
57
+ options: v.readonly ? ['ro'] : [],
58
+ };
59
+ }),
51
60
  ...(entrypoint
52
61
  ? { entrypoint: ifStringCastToArray(entrypoint) }
53
62
  : undefined),
@@ -65,5 +74,10 @@ export function createPodmanRunOptions(image, args, gpuOption) {
65
74
  create_working_dir: true,
66
75
  cgroups_mode: 'enabled',
67
76
  work_dir,
77
+ ...(security?.capDrop ? { cap_drop: security.capDrop } : {}),
78
+ // libpod takes limits under resource_limits; a top-level key is ignored.
79
+ ...(security?.pidsLimit
80
+ ? { resource_limits: { pids: { limit: security.pidsLimit } } }
81
+ : {}),
68
82
  };
69
83
  }
@@ -0,0 +1,66 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import type { JobDefinition } from '@nosana/sdk';
4
+ import type { Container } from 'dockerode';
5
+ import type { SshKeyGrant } from '../db/index.js';
6
+ import type { RunContainerArgs } from './containerOrchestration/interface.js';
7
+ /** Marks an operation's frpc sidecar that also runs sshgw. */
8
+ export declare const SSH_SIDECAR_SERVICE = "ssh-sidecar";
9
+ /** The only user name sshgw accepts; the hostname identifies the job. Shared with the client through @nosana/ssh. */
10
+ export declare const SSH_SIDECAR_USER = "nosana";
11
+ export declare const SSH_SIDECAR_KEYS_DIR = "/nosana-ssh";
12
+ /** Runtime label filters that select the running SSH sidecars of a job. */
13
+ export declare function sshSidecarFilters(jobId: string): string[];
14
+ /**
15
+ * Turns an operation's frpc sidecar config into its SSH sidecar. sshgw runs
16
+ * next to frpc and needs the runtime socket to exec into the workload; the
17
+ * image's entrypoint publishes the SSH proxy from the job and operation, keeps
18
+ * frpc off the socket and exits when either process stops so the runtime
19
+ * restarts the pair. `prepare` seeds the host key and authorized keys between
20
+ * create and start, so sshgw never answers with a fresh identity or an empty
21
+ * key file.
22
+ */
23
+ export declare function withSshSidecar(config: RunContainerArgs, sidecar: {
24
+ jobId: string;
25
+ opIndex: number;
26
+ /** Runtime name and the socket path as the daemon sees it on the host. */
27
+ runtime: {
28
+ name: string;
29
+ host: string;
30
+ };
31
+ prepare: (container: Container) => Promise<void>;
32
+ }): RunContainerArgs;
33
+ /**
34
+ * The keys a job definition grants at start: deduplicated by identity and
35
+ * dropped as a set if they break the shared limits. The SDK validator only
36
+ * lets `ssh.public_keys` through, so that is the only field read.
37
+ */
38
+ export declare function extractSshPublicKeys(jobDefinition: Pick<JobDefinition, 'ssh'>): string[];
39
+ /** An ed25519 host key as PKCS#8 PEM, which sshgw reads as it is. */
40
+ export declare function generateSshHostKey(): string;
41
+ /**
42
+ * Seeds a created, not yet started sidecar with the job's host key and its
43
+ * current authorized_keys, so sshgw finds both on its first start and never
44
+ * answers with a fresh identity or an empty key file.
45
+ */
46
+ export declare function seedSshSidecar(container: Container, seed: {
47
+ hostKey: string;
48
+ keys: SshKeyGrant[];
49
+ }): Promise<void>;
50
+ /**
51
+ * Rewrites a sidecar's authorized_keys, whether the sidecar is running or
52
+ * still only created. sshgw re-reads the file on every attempt and enforces
53
+ * expiry-time itself, so no reload or timer is needed; a torn read can only
54
+ * hide a key for an instant, never add one.
55
+ */
56
+ export declare function replaceSshAuthorizedKeys(container: Container, keys: SshKeyGrant[]): Promise<void>;
57
+ /**
58
+ * Builds an in-memory POSIX ustar archive of small regular files. Used to
59
+ * ship the key files into the sidecar container via the runtime's putArchive
60
+ * endpoint without pulling in a tar dependency.
61
+ */
62
+ export declare function createTar(files: Array<{
63
+ name: string;
64
+ content: Buffer;
65
+ mode: number;
66
+ }>): Buffer;
@@ -0,0 +1,176 @@
1
+ import { Readable } from 'stream';
2
+ import { generateKeyPairSync } from 'crypto';
3
+ import { JOB_LABEL, OP_INDEX_LABEL, SERVICE_LABEL, } from './containerOrchestration/labels.js';
4
+ import { getSshKeyIdentity, isValidSshPublicKey, MAX_SSH_PUBLIC_KEYS, MAX_SSH_PUBLIC_KEYS_BYTES, SSH_USERNAME, } from '@nosana/ssh';
5
+ /** Marks an operation's frpc sidecar that also runs sshgw. */
6
+ export const SSH_SIDECAR_SERVICE = 'ssh-sidecar';
7
+ /** The only user name sshgw accepts; the hostname identifies the job. Shared with the client through @nosana/ssh. */
8
+ export const SSH_SIDECAR_USER = SSH_USERNAME;
9
+ export const SSH_SIDECAR_KEYS_DIR = '/nosana-ssh';
10
+ const SSH_SIDECAR_KEYS_FILE = 'authorized_keys';
11
+ const SSH_SIDECAR_HOST_KEY_FILE = 'host_key';
12
+ const SSH_AUTHORIZED_KEY_OPTIONS = 'restrict,pty';
13
+ /** Runtime label filters that select the running SSH sidecars of a job. */
14
+ export function sshSidecarFilters(jobId) {
15
+ return [`${JOB_LABEL}=${jobId}`, `${SERVICE_LABEL}=${SSH_SIDECAR_SERVICE}`];
16
+ }
17
+ /**
18
+ * Turns an operation's frpc sidecar config into its SSH sidecar. sshgw runs
19
+ * next to frpc and needs the runtime socket to exec into the workload; the
20
+ * image's entrypoint publishes the SSH proxy from the job and operation, keeps
21
+ * frpc off the socket and exits when either process stops so the runtime
22
+ * restarts the pair. `prepare` seeds the host key and authorized keys between
23
+ * create and start, so sshgw never answers with a fresh identity or an empty
24
+ * key file.
25
+ */
26
+ export function withSshSidecar(config, sidecar) {
27
+ const socketDest = sidecar.runtime.name === 'podman'
28
+ ? '/run/podman/podman.sock'
29
+ : '/var/run/docker.sock';
30
+ return {
31
+ ...config,
32
+ restart_policy: 'on-failure',
33
+ env: {
34
+ ...config.env,
35
+ SSH_GATEWAY_ENABLED: '1',
36
+ SSHGW_JOB_ID: sidecar.jobId,
37
+ SSHGW_OP_INDEX: sidecar.opIndex.toString(),
38
+ DOCKER_HOST: `unix://${socketDest}`,
39
+ },
40
+ volumes: [
41
+ ...(config.volumes ?? []),
42
+ {
43
+ type: 'bind',
44
+ name: sidecar.runtime.host,
45
+ dest: socketDest,
46
+ readonly: true,
47
+ },
48
+ ],
49
+ labels: {
50
+ ...config.labels,
51
+ [JOB_LABEL]: sidecar.jobId,
52
+ [OP_INDEX_LABEL]: sidecar.opIndex.toString(),
53
+ [SERVICE_LABEL]: SSH_SIDECAR_SERVICE,
54
+ },
55
+ security: {
56
+ capDrop: ['AUDIT_WRITE', 'MKNOD', 'NET_RAW'],
57
+ pidsLimit: 128,
58
+ },
59
+ beforeStart: sidecar.prepare,
60
+ };
61
+ }
62
+ /**
63
+ * The keys a job definition grants at start: deduplicated by identity and
64
+ * dropped as a set if they break the shared limits. The SDK validator only
65
+ * lets `ssh.public_keys` through, so that is the only field read.
66
+ */
67
+ export function extractSshPublicKeys(jobDefinition) {
68
+ const byIdentity = new Map();
69
+ for (const value of jobDefinition.ssh?.public_keys ?? []) {
70
+ const key = typeof value === 'string' ? value.trim() : '';
71
+ if (!isValidSshPublicKey(key))
72
+ continue;
73
+ const identity = getSshKeyIdentity(key);
74
+ if (!byIdentity.has(identity))
75
+ byIdentity.set(identity, key);
76
+ }
77
+ const keys = [...byIdentity.values()];
78
+ const totalBytes = keys.reduce((sum, key) => sum + Buffer.byteLength(key), 0);
79
+ if (keys.length > MAX_SSH_PUBLIC_KEYS ||
80
+ totalBytes > MAX_SSH_PUBLIC_KEYS_BYTES) {
81
+ return [];
82
+ }
83
+ return keys;
84
+ }
85
+ /** An ed25519 host key as PKCS#8 PEM, which sshgw reads as it is. */
86
+ export function generateSshHostKey() {
87
+ return generateKeyPairSync('ed25519').privateKey.export({
88
+ type: 'pkcs8',
89
+ format: 'pem',
90
+ });
91
+ }
92
+ /**
93
+ * Seeds a created, not yet started sidecar with the job's host key and its
94
+ * current authorized_keys, so sshgw finds both on its first start and never
95
+ * answers with a fresh identity or an empty key file.
96
+ */
97
+ export async function seedSshSidecar(container, seed) {
98
+ await container.putArchive(Readable.from(createTar([
99
+ {
100
+ name: SSH_SIDECAR_HOST_KEY_FILE,
101
+ content: Buffer.from(seed.hostKey, 'utf8'),
102
+ mode: 0o600,
103
+ },
104
+ {
105
+ name: SSH_SIDECAR_KEYS_FILE,
106
+ content: formatAuthorizedKeys(seed.keys),
107
+ mode: 0o600,
108
+ },
109
+ ])), { path: SSH_SIDECAR_KEYS_DIR });
110
+ }
111
+ /**
112
+ * Rewrites a sidecar's authorized_keys, whether the sidecar is running or
113
+ * still only created. sshgw re-reads the file on every attempt and enforces
114
+ * expiry-time itself, so no reload or timer is needed; a torn read can only
115
+ * hide a key for an instant, never add one.
116
+ */
117
+ export async function replaceSshAuthorizedKeys(container, keys) {
118
+ await container.putArchive(Readable.from(createTar([
119
+ {
120
+ name: SSH_SIDECAR_KEYS_FILE,
121
+ content: formatAuthorizedKeys(keys),
122
+ mode: 0o600,
123
+ },
124
+ ])), { path: SSH_SIDECAR_KEYS_DIR });
125
+ }
126
+ function formatAuthorizedKeys(keys) {
127
+ const lines = keys.map(({ sshPublicKey, expiresAt }) => {
128
+ if (!isValidSshPublicKey(sshPublicKey))
129
+ throw new Error('Invalid SSH public key');
130
+ const expiry = expiresAt === undefined
131
+ ? ''
132
+ : `,expiry-time="${new Date(expiresAt)
133
+ .toISOString()
134
+ .slice(0, 19)
135
+ .replace(/[-:T]/g, '')}Z"`;
136
+ return `${SSH_AUTHORIZED_KEY_OPTIONS}${expiry} ${sshPublicKey}`;
137
+ });
138
+ return Buffer.from(lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
139
+ }
140
+ /**
141
+ * Builds an in-memory POSIX ustar archive of small regular files. Used to
142
+ * ship the key files into the sidecar container via the runtime's putArchive
143
+ * endpoint without pulling in a tar dependency.
144
+ */
145
+ export function createTar(files) {
146
+ // Two zero-filled 512-byte blocks mark the end of the archive.
147
+ const trailer = Buffer.alloc(1024, 0);
148
+ return Buffer.concat([...files.map(tarEntry), trailer]);
149
+ }
150
+ function tarEntry({ name, content, mode, }) {
151
+ const header = Buffer.alloc(512, 0);
152
+ const writeOctal = (value, offset, length) => {
153
+ // length includes the trailing NUL terminator.
154
+ const str = value.toString(8).padStart(length - 1, '0');
155
+ header.write(`${str}\0`, offset, length, 'ascii');
156
+ };
157
+ header.write(name, 0, 100, 'utf8'); // name
158
+ writeOctal(mode & 0o7777, 100, 8); // mode
159
+ writeOctal(0, 108, 8); // uid
160
+ writeOctal(0, 116, 8); // gid
161
+ writeOctal(content.length, 124, 12); // size
162
+ writeOctal(Math.floor(Date.now() / 1000), 136, 12); // mtime
163
+ header.write(' ', 148, 8, 'ascii'); // checksum placeholder (8 spaces)
164
+ header.write('0', 156, 1, 'ascii'); // typeflag: normal file
165
+ header.write('ustar', 257, 5, 'ascii'); // magic
166
+ header.write('00', 263, 2, 'ascii'); // version
167
+ let checksum = 0;
168
+ for (let i = 0; i < 512; i++)
169
+ checksum += header[i];
170
+ // 6 octal digits, NUL, space.
171
+ header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii');
172
+ const paddedContentLength = Math.ceil(content.length / 512) * 512;
173
+ const body = Buffer.alloc(paddedContentLength, 0);
174
+ content.copy(body);
175
+ return Buffer.concat([header, body]);
176
+ }
@@ -0,0 +1,24 @@
1
+ /// <reference types="node" />
2
+ import type { Duplex } from 'stream';
3
+ import type { ContainerOrchestrationInterface } from './containerOrchestration/interface.js';
4
+ export type TerminalSession = {
5
+ stream: Duplex;
6
+ resize: (size: {
7
+ cols?: number;
8
+ rows?: number;
9
+ }) => Promise<void>;
10
+ inspectExitCode: () => Promise<number | null>;
11
+ close: () => void;
12
+ };
13
+ /**
14
+ * Finds the single running container that belongs to a job (optionally scoped
15
+ * to a specific operation) and opens an interactive PTY exec session into it.
16
+ *
17
+ * Mirrors the selection sshgw performs in the sidecar, but runs in-process
18
+ * over the node's own runtime socket.
19
+ */
20
+ export declare function createJobTerminalExec(orchestration: ContainerOrchestrationInterface, jobId: string, options?: {
21
+ op?: string;
22
+ cols?: number;
23
+ rows?: number;
24
+ }): Promise<TerminalSession>;