codify-plugin-lib 1.0.108 → 1.0.110
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/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/plugin/plugin.d.ts +2 -0
- package/dist/plugin/plugin.js +6 -1
- package/dist/pty/background-pty.d.ts +19 -0
- package/dist/pty/background-pty.js +119 -0
- package/dist/pty/index.d.ts +24 -0
- package/dist/pty/index.js +15 -0
- package/dist/pty/promise-queue.d.ts +5 -0
- package/dist/pty/promise-queue.js +26 -0
- package/dist/pty/vitest.config.d.ts +2 -0
- package/dist/pty/vitest.config.js +11 -0
- package/dist/resource/resource-controller.js +2 -2
- package/dist/utils/debug.d.ts +2 -0
- package/dist/utils/debug.js +10 -0
- package/dist/utils/pty-local-storage.d.ts +3 -0
- package/dist/utils/pty-local-storage.js +2 -0
- package/package.json +5 -2
- package/src/index.ts +2 -1
- package/src/plugin/plugin.ts +11 -5
- package/src/pty/background-pty.test.ts +69 -0
- package/src/pty/background-pty.ts +147 -0
- package/src/pty/index.test.ts +129 -0
- package/src/pty/index.ts +39 -0
- package/src/pty/promise-queue.ts +33 -0
- package/src/pty/vitest.config.ts +12 -0
- package/src/resource/resource-controller.ts +2 -2
- package/src/utils/debug.ts +11 -0
- package/src/utils/pty-local-storage.ts +3 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,9 +4,9 @@ export * from './plan/change-set.js';
|
|
|
4
4
|
export * from './plan/plan.js';
|
|
5
5
|
export * from './plan/plan-types.js';
|
|
6
6
|
export * from './plugin/plugin.js';
|
|
7
|
+
export * from './pty/index.js';
|
|
7
8
|
export * from './resource/parsed-resource-settings.js';
|
|
8
9
|
export * from './resource/resource.js';
|
|
9
10
|
export * from './resource/resource-settings.js';
|
|
10
11
|
export * from './stateful-parameter/stateful-parameter.js';
|
|
11
|
-
export * from './utils/utils.js';
|
|
12
12
|
export declare function runPlugin(plugin: Plugin): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,12 @@ export * from './plan/change-set.js';
|
|
|
4
4
|
export * from './plan/plan.js';
|
|
5
5
|
export * from './plan/plan-types.js';
|
|
6
6
|
export * from './plugin/plugin.js';
|
|
7
|
+
// export * from './utils/utils.js'
|
|
8
|
+
export * from './pty/index.js';
|
|
7
9
|
export * from './resource/parsed-resource-settings.js';
|
|
8
10
|
export * from './resource/resource.js';
|
|
9
11
|
export * from './resource/resource-settings.js';
|
|
10
12
|
export * from './stateful-parameter/stateful-parameter.js';
|
|
11
|
-
export * from './utils/utils.js';
|
|
12
13
|
export async function runPlugin(plugin) {
|
|
13
14
|
const messageHandler = new MessageHandler(plugin);
|
|
14
15
|
process.on('message', (message) => messageHandler.onMessage(message));
|
package/dist/plugin/plugin.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { ApplyRequestData, GetResourceInfoRequestData, GetResourceInfoResponseDa
|
|
|
2
2
|
import { Plan } from '../plan/plan.js';
|
|
3
3
|
import { Resource } from '../resource/resource.js';
|
|
4
4
|
import { ResourceController } from '../resource/resource-controller.js';
|
|
5
|
+
import { BackgroundPty } from '../pty/background-pty.js';
|
|
5
6
|
export declare class Plugin {
|
|
6
7
|
name: string;
|
|
7
8
|
resourceControllers: Map<string, ResourceController<ResourceConfig>>;
|
|
8
9
|
planStorage: Map<string, Plan<any>>;
|
|
10
|
+
planPty: BackgroundPty;
|
|
9
11
|
constructor(name: string, resourceControllers: Map<string, ResourceController<ResourceConfig>>);
|
|
10
12
|
static create(name: string, resources: Resource<any>[]): Plugin;
|
|
11
13
|
initialize(): Promise<InitializeResponseData>;
|
package/dist/plugin/plugin.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Plan } from '../plan/plan.js';
|
|
2
2
|
import { ResourceController } from '../resource/resource-controller.js';
|
|
3
|
+
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
|
|
4
|
+
import { BackgroundPty } from '../pty/background-pty.js';
|
|
3
5
|
export class Plugin {
|
|
4
6
|
name;
|
|
5
7
|
resourceControllers;
|
|
6
8
|
planStorage;
|
|
9
|
+
planPty = new BackgroundPty();
|
|
7
10
|
constructor(name, resourceControllers) {
|
|
8
11
|
this.name = name;
|
|
9
12
|
this.resourceControllers = resourceControllers;
|
|
@@ -79,7 +82,9 @@ export class Plugin {
|
|
|
79
82
|
if (!type || !this.resourceControllers.has(type)) {
|
|
80
83
|
throw new Error(`Resource type not found: ${type}`);
|
|
81
84
|
}
|
|
82
|
-
const plan = await
|
|
85
|
+
const plan = await ptyLocalStorage.run(this.planPty, async () => {
|
|
86
|
+
return this.resourceControllers.get(type).plan(data.desired ?? null, data.state ?? null, data.isStateful);
|
|
87
|
+
});
|
|
83
88
|
this.planStorage.set(plan.id, plan);
|
|
84
89
|
return plan.toResponse();
|
|
85
90
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { IPty, SpawnOptions, SpawnResult } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The background pty is a specialized pty designed for speed. It can launch multiple tasks
|
|
4
|
+
* in parallel by moving them to the background. It attaches unix FIFO pipes to each process
|
|
5
|
+
* to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
|
|
6
|
+
* without a tty (or even a stdin) attached so interactive commands will not work.
|
|
7
|
+
*/
|
|
8
|
+
export declare class BackgroundPty implements IPty {
|
|
9
|
+
private basePty;
|
|
10
|
+
private promiseQueue;
|
|
11
|
+
constructor();
|
|
12
|
+
spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
|
|
13
|
+
spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
|
|
14
|
+
kill(): Promise<{
|
|
15
|
+
exitCode: number;
|
|
16
|
+
signal?: number | undefined;
|
|
17
|
+
}>;
|
|
18
|
+
private initialize;
|
|
19
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import * as cp from 'node:child_process';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
import pty from 'node-pty';
|
|
7
|
+
import stripAnsi from 'strip-ansi';
|
|
8
|
+
import { SpawnError } from './index.js';
|
|
9
|
+
import { PromiseQueue } from './promise-queue.js';
|
|
10
|
+
import { debugLog } from '../utils/debug.js';
|
|
11
|
+
EventEmitter.defaultMaxListeners = 1000;
|
|
12
|
+
/**
|
|
13
|
+
* The background pty is a specialized pty designed for speed. It can launch multiple tasks
|
|
14
|
+
* in parallel by moving them to the background. It attaches unix FIFO pipes to each process
|
|
15
|
+
* to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
|
|
16
|
+
* without a tty (or even a stdin) attached so interactive commands will not work.
|
|
17
|
+
*/
|
|
18
|
+
export class BackgroundPty {
|
|
19
|
+
basePty = pty.spawn('zsh', ['-i'], {
|
|
20
|
+
env: process.env, name: nanoid(6),
|
|
21
|
+
handleFlowControl: true
|
|
22
|
+
});
|
|
23
|
+
promiseQueue = new PromiseQueue();
|
|
24
|
+
constructor() {
|
|
25
|
+
this.initialize();
|
|
26
|
+
}
|
|
27
|
+
async spawn(cmd, options) {
|
|
28
|
+
const spawnResult = await this.spawnSafe(cmd, options);
|
|
29
|
+
if (spawnResult.status !== 'success') {
|
|
30
|
+
throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
|
|
31
|
+
}
|
|
32
|
+
return spawnResult;
|
|
33
|
+
}
|
|
34
|
+
async spawnSafe(cmd, options) {
|
|
35
|
+
// cid is command id
|
|
36
|
+
const cid = nanoid(10);
|
|
37
|
+
debugLog(cid);
|
|
38
|
+
await new Promise((resolve) => {
|
|
39
|
+
// 600 permissions means only the current user will be able to rw from the FIFO
|
|
40
|
+
// Create in /tmp so it could be automatically cleaned up if the clean-up was missed
|
|
41
|
+
const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
|
|
42
|
+
mkfifoSpawn.on('close', () => {
|
|
43
|
+
resolve(null);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
// Use read and write so that the pipe doesn't close
|
|
47
|
+
const fileHandle = await fs.open(`/tmp/${cid}`, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
|
|
48
|
+
let pipe;
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
pipe = new net.Socket({ fd: fileHandle.fd });
|
|
51
|
+
// pipe.pipe(process.stdout);
|
|
52
|
+
let output = '';
|
|
53
|
+
pipe.on('data', (data) => {
|
|
54
|
+
output += data.toString();
|
|
55
|
+
if (output.includes('%%%done%%%"')) {
|
|
56
|
+
const truncOutput = output.replace('%%%done%%%"\n', '');
|
|
57
|
+
const [data, exit] = truncOutput.split('%%%');
|
|
58
|
+
// Clean up trailing \n newline if it exists
|
|
59
|
+
let strippedData = stripAnsi(data);
|
|
60
|
+
if (strippedData.endsWith('\n')) {
|
|
61
|
+
strippedData = strippedData.slice(0, -1);
|
|
62
|
+
}
|
|
63
|
+
resolve({
|
|
64
|
+
status: Number.parseInt(exit ?? 1, 10) === 0 ? 'success' : 'error',
|
|
65
|
+
exitCode: Number.parseInt(exit ?? 1, 10),
|
|
66
|
+
data: strippedData,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
this.promiseQueue.run(async () => new Promise((resolve) => {
|
|
71
|
+
const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
|
|
72
|
+
// Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
|
|
73
|
+
// Done is used to denote the end of the command
|
|
74
|
+
// Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
|
|
75
|
+
const command = `((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`;
|
|
76
|
+
let output = '';
|
|
77
|
+
const listener = this.basePty.onData((data) => {
|
|
78
|
+
output += data;
|
|
79
|
+
if (output.includes(`%%%done%%%${cid}"`)) {
|
|
80
|
+
listener.dispose();
|
|
81
|
+
resolve(null);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
// console.log(`Running command ${cmd}`)
|
|
85
|
+
this.basePty.write(`${command}\r`);
|
|
86
|
+
}));
|
|
87
|
+
}).finally(async () => {
|
|
88
|
+
// console.log('finally');
|
|
89
|
+
// await fileHandle?.close();
|
|
90
|
+
await fs.rm(`/tmp/${cid}`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async kill() {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
this.basePty.onExit((status) => {
|
|
96
|
+
resolve(status);
|
|
97
|
+
});
|
|
98
|
+
this.basePty.kill();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async initialize() {
|
|
102
|
+
// this.basePty.onData((data: string) => process.stdout.write(data));
|
|
103
|
+
await this.promiseQueue.run(async () => {
|
|
104
|
+
let outputBuffer = '';
|
|
105
|
+
return new Promise(resolve => {
|
|
106
|
+
this.basePty.write('unset PS1;\n');
|
|
107
|
+
this.basePty.write('unset PS0;\n');
|
|
108
|
+
this.basePty.write('echo setup complete\\"\n');
|
|
109
|
+
const listener = this.basePty.onData((data) => {
|
|
110
|
+
outputBuffer += data;
|
|
111
|
+
if (outputBuffer.includes('setup complete"')) {
|
|
112
|
+
listener.dispose();
|
|
113
|
+
resolve(null);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SpawnResult {
|
|
2
|
+
status: 'success' | 'error';
|
|
3
|
+
exitCode: number;
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SpawnOptions {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export declare class SpawnError extends Error {
|
|
11
|
+
data: string;
|
|
12
|
+
cmd: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
constructor(cmd: string, exitCode: number, data: string);
|
|
15
|
+
}
|
|
16
|
+
export interface IPty {
|
|
17
|
+
spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
|
|
18
|
+
spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
|
|
19
|
+
kill(): Promise<{
|
|
20
|
+
exitCode: number;
|
|
21
|
+
signal?: number | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export declare function getPty(): IPty;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
|
|
2
|
+
export class SpawnError extends Error {
|
|
3
|
+
data;
|
|
4
|
+
cmd;
|
|
5
|
+
exitCode;
|
|
6
|
+
constructor(cmd, exitCode, data) {
|
|
7
|
+
super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`);
|
|
8
|
+
this.data = data;
|
|
9
|
+
this.cmd = cmd;
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function getPty() {
|
|
14
|
+
return ptyLocalStorage.getStore();
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import EventEmitter from 'node:events';
|
|
3
|
+
export class PromiseQueue {
|
|
4
|
+
// Cid stands for command id;
|
|
5
|
+
queue = [];
|
|
6
|
+
eventBus = new EventEmitter();
|
|
7
|
+
async run(fn) {
|
|
8
|
+
const cid = nanoid();
|
|
9
|
+
this.queue.push({ cid, fn });
|
|
10
|
+
if (this.queue.length !== 1) {
|
|
11
|
+
await new Promise((resolve) => {
|
|
12
|
+
const listener = () => {
|
|
13
|
+
if (this.queue[0].cid === cid) {
|
|
14
|
+
this.eventBus.removeListener('dequeue', listener);
|
|
15
|
+
resolve(null);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
this.eventBus.on('dequeue', listener);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const result = await fn();
|
|
22
|
+
this.queue.shift();
|
|
23
|
+
this.eventBus.emit('dequeue');
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -263,13 +263,13 @@ ${JSON.stringify(refresh, null, 2)}
|
|
|
263
263
|
const result = {};
|
|
264
264
|
const sortedEntries = Object.entries(statefulParametersConfig)
|
|
265
265
|
.sort(([key1], [key2]) => this.parsedSettings.statefulParameterOrder.get(key1) - this.parsedSettings.statefulParameterOrder.get(key2));
|
|
266
|
-
|
|
266
|
+
await Promise.all(sortedEntries.map(async ([key, desiredValue]) => {
|
|
267
267
|
const statefulParameter = this.parsedSettings.statefulParameters.get(key);
|
|
268
268
|
if (!statefulParameter) {
|
|
269
269
|
throw new Error(`Stateful parameter ${key} was not found`);
|
|
270
270
|
}
|
|
271
271
|
result[key] = await statefulParameter.refresh(desiredValue ?? null, allParameters);
|
|
272
|
-
}
|
|
272
|
+
}));
|
|
273
273
|
return result;
|
|
274
274
|
}
|
|
275
275
|
validatePlanInputs(desired, current, statefulMode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codify-plugin-lib",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.110",
|
|
4
4
|
"description": "Library plugin library",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
"codify-schemas": "1.0.53",
|
|
18
18
|
"@npmcli/promise-spawn": "^7.0.1",
|
|
19
19
|
"uuid": "^10.0.0",
|
|
20
|
-
"lodash.isequal": "^4.5.0"
|
|
20
|
+
"lodash.isequal": "^4.5.0",
|
|
21
|
+
"nanoid": "^5.0.9",
|
|
22
|
+
"node-pty": "^1.0.0",
|
|
23
|
+
"strip-ansi": "^7.1.0"
|
|
21
24
|
},
|
|
22
25
|
"devDependencies": {
|
|
23
26
|
"@oclif/prettier-config": "^0.2.1",
|
package/src/index.ts
CHANGED
|
@@ -6,11 +6,12 @@ export * from './plan/change-set.js'
|
|
|
6
6
|
export * from './plan/plan.js'
|
|
7
7
|
export * from './plan/plan-types.js'
|
|
8
8
|
export * from './plugin/plugin.js'
|
|
9
|
+
// export * from './utils/utils.js'
|
|
10
|
+
export * from './pty/index.js'
|
|
9
11
|
export * from './resource/parsed-resource-settings.js';
|
|
10
12
|
export * from './resource/resource.js'
|
|
11
13
|
export * from './resource/resource-settings.js'
|
|
12
14
|
export * from './stateful-parameter/stateful-parameter.js'
|
|
13
|
-
export * from './utils/utils.js'
|
|
14
15
|
|
|
15
16
|
export async function runPlugin(plugin: Plugin) {
|
|
16
17
|
const messageHandler = new MessageHandler(plugin);
|
package/src/plugin/plugin.ts
CHANGED
|
@@ -16,9 +16,12 @@ import {
|
|
|
16
16
|
import { Plan } from '../plan/plan.js';
|
|
17
17
|
import { Resource } from '../resource/resource.js';
|
|
18
18
|
import { ResourceController } from '../resource/resource-controller.js';
|
|
19
|
+
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
|
|
20
|
+
import { BackgroundPty } from '../pty/background-pty.js';
|
|
19
21
|
|
|
20
22
|
export class Plugin {
|
|
21
23
|
planStorage: Map<string, Plan<any>>;
|
|
24
|
+
planPty = new BackgroundPty();
|
|
22
25
|
|
|
23
26
|
constructor(
|
|
24
27
|
public name: string,
|
|
@@ -119,11 +122,14 @@ export class Plugin {
|
|
|
119
122
|
throw new Error(`Resource type not found: ${type}`);
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
const plan = await this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const plan = await ptyLocalStorage.run(this.planPty, async () => {
|
|
126
|
+
return this.resourceControllers.get(type)!.plan(
|
|
127
|
+
data.desired ?? null,
|
|
128
|
+
data.state ?? null,
|
|
129
|
+
data.isStateful
|
|
130
|
+
);
|
|
131
|
+
})
|
|
132
|
+
|
|
127
133
|
this.planStorage.set(plan.id, plan);
|
|
128
134
|
|
|
129
135
|
return plan.toResponse();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { BackgroundPty } from './background-pty.js';
|
|
3
|
+
|
|
4
|
+
describe('BackgroundPty tests', () => {
|
|
5
|
+
it('Can launch a simple command', async () => {
|
|
6
|
+
const pty = new BackgroundPty();
|
|
7
|
+
|
|
8
|
+
const result = await pty.spawnSafe('ls');
|
|
9
|
+
expect(result).toMatchObject({
|
|
10
|
+
status: 'success',
|
|
11
|
+
exitCode: 0,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
const exitCode = await pty.kill();
|
|
16
|
+
expect(exitCode).toMatchObject({
|
|
17
|
+
exitCode: 1,
|
|
18
|
+
signal: 0,
|
|
19
|
+
});
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('Can launch 100 commands in parallel', { timeout: 15000 }, async () => {
|
|
23
|
+
const pty = new BackgroundPty();
|
|
24
|
+
|
|
25
|
+
const fn = async () => pty.spawnSafe('ls');
|
|
26
|
+
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
Array.from({ length: 100 }, (_, i) => i + 1)
|
|
29
|
+
.map(() => fn())
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(results.length).to.eq(100);
|
|
33
|
+
expect(results.every((r) => r.exitCode === 0))
|
|
34
|
+
|
|
35
|
+
await pty.kill();
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('Reports back the correct exit code and status', async () => {
|
|
39
|
+
const pty = new BackgroundPty();
|
|
40
|
+
|
|
41
|
+
const resultSuccess = await pty.spawnSafe('ls');
|
|
42
|
+
expect(resultSuccess).toMatchObject({
|
|
43
|
+
status: 'success',
|
|
44
|
+
exitCode: 0,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const resultFailed = await pty.spawnSafe('which sjkdhsakjdhjkash');
|
|
48
|
+
expect(resultFailed).toMatchObject({
|
|
49
|
+
status: 'error',
|
|
50
|
+
exitCode: 1,
|
|
51
|
+
data: 'sjkdhsakjdhjkash not found' // This might change on different os or shells. Keep for now.
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await pty.kill();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('Can use a different cwd', async () => {
|
|
58
|
+
const pty = new BackgroundPty();
|
|
59
|
+
|
|
60
|
+
const resultSuccess = await pty.spawnSafe('pwd', { cwd: '/tmp' });
|
|
61
|
+
expect(resultSuccess).toMatchObject({
|
|
62
|
+
status: 'success',
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
data: '/tmp'
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await pty.kill();
|
|
68
|
+
});
|
|
69
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import * as cp from 'node:child_process';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
import pty from 'node-pty';
|
|
7
|
+
import stripAnsi from 'strip-ansi';
|
|
8
|
+
|
|
9
|
+
import { IPty, SpawnError, SpawnOptions, SpawnResult } from './index.js';
|
|
10
|
+
import { PromiseQueue } from './promise-queue.js';
|
|
11
|
+
import { debugLog } from '../utils/debug.js';
|
|
12
|
+
|
|
13
|
+
EventEmitter.defaultMaxListeners = 1000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The background pty is a specialized pty designed for speed. It can launch multiple tasks
|
|
17
|
+
* in parallel by moving them to the background. It attaches unix FIFO pipes to each process
|
|
18
|
+
* to listen to stdout and stderr. One limitation of the BackgroundPty is that the tasks run
|
|
19
|
+
* without a tty (or even a stdin) attached so interactive commands will not work.
|
|
20
|
+
*/
|
|
21
|
+
export class BackgroundPty implements IPty {
|
|
22
|
+
private basePty = pty.spawn('zsh', ['-i'], {
|
|
23
|
+
env: process.env, name: nanoid(6),
|
|
24
|
+
handleFlowControl: true
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
private promiseQueue = new PromiseQueue();
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
this.initialize();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
|
|
34
|
+
const spawnResult = await this.spawnSafe(cmd, options);
|
|
35
|
+
|
|
36
|
+
if (spawnResult.status !== 'success') {
|
|
37
|
+
throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return spawnResult;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult> {
|
|
44
|
+
// cid is command id
|
|
45
|
+
const cid = nanoid(10);
|
|
46
|
+
debugLog(cid);
|
|
47
|
+
|
|
48
|
+
await new Promise((resolve) => {
|
|
49
|
+
// 600 permissions means only the current user will be able to rw from the FIFO
|
|
50
|
+
// Create in /tmp so it could be automatically cleaned up if the clean-up was missed
|
|
51
|
+
const mkfifoSpawn = cp.spawn('mkfifo', ['-m', '600', `/tmp/${cid}`]);
|
|
52
|
+
mkfifoSpawn.on('close', () => {
|
|
53
|
+
resolve(null);
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Use read and write so that the pipe doesn't close
|
|
58
|
+
const fileHandle = await fs.open(`/tmp/${cid}`, fs.constants.O_RDWR | fs.constants.O_NONBLOCK);
|
|
59
|
+
let pipe: net.Socket;
|
|
60
|
+
|
|
61
|
+
return new Promise<SpawnResult>((resolve) => {
|
|
62
|
+
pipe = new net.Socket({ fd: fileHandle.fd });
|
|
63
|
+
|
|
64
|
+
// pipe.pipe(process.stdout);
|
|
65
|
+
|
|
66
|
+
let output = '';
|
|
67
|
+
pipe.on('data', (data) => {
|
|
68
|
+
output += data.toString();
|
|
69
|
+
|
|
70
|
+
if (output.includes('%%%done%%%"')) {
|
|
71
|
+
const truncOutput = output.replace('%%%done%%%"\n', '');
|
|
72
|
+
const [data, exit] = truncOutput.split('%%%');
|
|
73
|
+
|
|
74
|
+
// Clean up trailing \n newline if it exists
|
|
75
|
+
let strippedData = stripAnsi(data);
|
|
76
|
+
if (strippedData.endsWith('\n')) {
|
|
77
|
+
strippedData = strippedData.slice(0, -1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resolve(<SpawnResult>{
|
|
81
|
+
status: Number.parseInt(exit ?? 1, 10) === 0 ? 'success' : 'error',
|
|
82
|
+
exitCode: Number.parseInt(exit ?? 1, 10),
|
|
83
|
+
data: strippedData,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
this.promiseQueue.run(async () => new Promise((resolve) => {
|
|
89
|
+
const cdCommand = options?.cwd ? `cd ${options.cwd}; ` : '';
|
|
90
|
+
// Redirecting everything to the pipe and running in theb background avoids most if not all back-pressure problems
|
|
91
|
+
// Done is used to denote the end of the command
|
|
92
|
+
// Use the \\" at the end differentiate between command and response. \\" will evaluate to " in the terminal
|
|
93
|
+
const command = `((${cdCommand}${cmd}; echo %%%$?%%%done%%%\\") > "/tmp/${cid}" 2>&1 &); echo %%%done%%%${cid}\\";`
|
|
94
|
+
|
|
95
|
+
let output = '';
|
|
96
|
+
const listener = this.basePty.onData((data: any) => {
|
|
97
|
+
output += data;
|
|
98
|
+
|
|
99
|
+
if (output.includes(`%%%done%%%${cid}"`)) {
|
|
100
|
+
listener.dispose();
|
|
101
|
+
resolve(null);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// console.log(`Running command ${cmd}`)
|
|
106
|
+
this.basePty.write(`${command}\r`);
|
|
107
|
+
|
|
108
|
+
}));
|
|
109
|
+
}).finally(async () => {
|
|
110
|
+
// console.log('finally');
|
|
111
|
+
// await fileHandle?.close();
|
|
112
|
+
await fs.rm(`/tmp/${cid}`);
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async kill(): Promise<{ exitCode: number, signal?: number | undefined }> {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
this.basePty.onExit((status) => {
|
|
119
|
+
resolve(status);
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
this.basePty.kill()
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async initialize() {
|
|
127
|
+
// this.basePty.onData((data: string) => process.stdout.write(data));
|
|
128
|
+
|
|
129
|
+
await this.promiseQueue.run(async () => {
|
|
130
|
+
let outputBuffer = '';
|
|
131
|
+
|
|
132
|
+
return new Promise(resolve => {
|
|
133
|
+
this.basePty.write('unset PS1;\n');
|
|
134
|
+
this.basePty.write('unset PS0;\n')
|
|
135
|
+
this.basePty.write('echo setup complete\\"\n')
|
|
136
|
+
|
|
137
|
+
const listener = this.basePty.onData((data: string) => {
|
|
138
|
+
outputBuffer += data;
|
|
139
|
+
if (outputBuffer.includes('setup complete"')) {
|
|
140
|
+
listener.dispose();
|
|
141
|
+
resolve(null);
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it, vitest } from 'vitest';
|
|
2
|
+
import { TestConfig, TestResource } from '../utils/test-utils.test.js';
|
|
3
|
+
import { getPty, IPty } from './index.js';
|
|
4
|
+
import { Plugin } from '../plugin/plugin.js'
|
|
5
|
+
import { CreatePlan } from '../plan/plan-types.js';
|
|
6
|
+
import { ResourceOperation } from 'codify-schemas';
|
|
7
|
+
import { ResourceSettings } from '../resource/resource-settings.js';
|
|
8
|
+
|
|
9
|
+
describe('General tests for PTYs', () => {
|
|
10
|
+
it('Can get pty within refresh', async () => {
|
|
11
|
+
const testResource = new class extends TestResource {
|
|
12
|
+
async refresh(): Promise<Partial<TestConfig> | null> {
|
|
13
|
+
const $ = getPty();
|
|
14
|
+
const lsResult = await $.spawnSafe('ls');
|
|
15
|
+
|
|
16
|
+
expect(lsResult.exitCode).to.eq(0);
|
|
17
|
+
expect(lsResult.data).to.be.not.null;
|
|
18
|
+
expect(lsResult.status).to.eq('success');
|
|
19
|
+
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const spy = vitest.spyOn(testResource, 'refresh')
|
|
25
|
+
|
|
26
|
+
const plugin = Plugin.create('test plugin', [testResource])
|
|
27
|
+
const plan = await plugin.plan({
|
|
28
|
+
desired: {
|
|
29
|
+
type: 'type'
|
|
30
|
+
},
|
|
31
|
+
state: undefined,
|
|
32
|
+
isStateful: false,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(plan).toMatchObject({
|
|
36
|
+
operation: 'noop',
|
|
37
|
+
resourceType: 'type',
|
|
38
|
+
})
|
|
39
|
+
expect(spy).toHaveBeenCalledOnce()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('The same pty instance is shared cross multiple resources', async () => {
|
|
43
|
+
let pty1: IPty;
|
|
44
|
+
let pty2: IPty;
|
|
45
|
+
|
|
46
|
+
const testResource1 = new class extends TestResource {
|
|
47
|
+
getSettings(): ResourceSettings<TestConfig> {
|
|
48
|
+
return {
|
|
49
|
+
id: 'type1'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async refresh(): Promise<Partial<TestConfig> | null> {
|
|
54
|
+
const $ = getPty();
|
|
55
|
+
const lsResult = await $.spawnSafe('ls');
|
|
56
|
+
|
|
57
|
+
expect(lsResult.exitCode).to.eq(0);
|
|
58
|
+
pty1 = $;
|
|
59
|
+
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const testResource2 = new class extends TestResource {
|
|
65
|
+
getSettings(): ResourceSettings<TestConfig> {
|
|
66
|
+
return {
|
|
67
|
+
id: 'type2',
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async refresh(): Promise<Partial<TestConfig> | null> {
|
|
72
|
+
const $ = getPty();
|
|
73
|
+
const pwdResult = await $.spawnSafe('pwd');
|
|
74
|
+
|
|
75
|
+
expect(pwdResult.exitCode).to.eq(0);
|
|
76
|
+
pty2 = $;
|
|
77
|
+
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spy1 = vitest.spyOn(testResource1, 'refresh')
|
|
83
|
+
const spy2 = vitest.spyOn(testResource2, 'refresh')
|
|
84
|
+
|
|
85
|
+
const plugin = Plugin.create('test plugin', [testResource1, testResource2]);
|
|
86
|
+
await plugin.plan({
|
|
87
|
+
desired: {
|
|
88
|
+
type: 'type1'
|
|
89
|
+
},
|
|
90
|
+
state: undefined,
|
|
91
|
+
isStateful: false,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await plugin.plan({
|
|
95
|
+
desired: {
|
|
96
|
+
type: 'type2'
|
|
97
|
+
},
|
|
98
|
+
state: undefined,
|
|
99
|
+
isStateful: false,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(spy1).toHaveBeenCalledOnce();
|
|
103
|
+
expect(spy2).toHaveBeenCalledOnce();
|
|
104
|
+
|
|
105
|
+
// The main check here is that the refresh method for both are sharing the same pty instance.
|
|
106
|
+
expect(pty1).to.eq(pty2);
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('Currently pty not available for apply', async () => {
|
|
110
|
+
const testResource = new class extends TestResource {
|
|
111
|
+
create(plan: CreatePlan<TestConfig>): Promise<void> {
|
|
112
|
+
const $ = getPty();
|
|
113
|
+
expect($).to.be.undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spy = vitest.spyOn(testResource, 'create')
|
|
118
|
+
|
|
119
|
+
const plugin = Plugin.create('test plugin', [testResource])
|
|
120
|
+
await plugin.apply({
|
|
121
|
+
plan: {
|
|
122
|
+
operation: ResourceOperation.CREATE,
|
|
123
|
+
resourceType: 'type',
|
|
124
|
+
parameters: [],
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
expect(spy).toHaveBeenCalledOnce()
|
|
128
|
+
})
|
|
129
|
+
})
|
package/src/pty/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ptyLocalStorage } from '../utils/pty-local-storage.js';
|
|
2
|
+
|
|
3
|
+
export interface SpawnResult {
|
|
4
|
+
status: 'success' | 'error';
|
|
5
|
+
exitCode: number;
|
|
6
|
+
data: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SpawnOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env?: Record<string, unknown>,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SpawnError extends Error {
|
|
15
|
+
data: string;
|
|
16
|
+
cmd: string;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
|
|
19
|
+
constructor(cmd: string, exitCode: number, data: string) {
|
|
20
|
+
super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`);
|
|
21
|
+
|
|
22
|
+
this.data = data;
|
|
23
|
+
this.cmd = cmd;
|
|
24
|
+
this.exitCode = exitCode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IPty {
|
|
30
|
+
spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>
|
|
31
|
+
|
|
32
|
+
spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>
|
|
33
|
+
|
|
34
|
+
kill(): Promise<{ exitCode: number, signal?: number | undefined }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getPty(): IPty {
|
|
38
|
+
return ptyLocalStorage.getStore() as IPty;
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import EventEmitter from 'node:events';
|
|
3
|
+
|
|
4
|
+
export class PromiseQueue {
|
|
5
|
+
// Cid stands for command id;
|
|
6
|
+
private queue: Array<{ cid: string, fn: () => Promise<any> | any }> = [];
|
|
7
|
+
private eventBus = new EventEmitter()
|
|
8
|
+
|
|
9
|
+
async run<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
10
|
+
const cid = nanoid();
|
|
11
|
+
this.queue.push({ cid, fn })
|
|
12
|
+
|
|
13
|
+
if (this.queue.length !== 1) {
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
const listener = () => {
|
|
16
|
+
if (this.queue[0].cid === cid) {
|
|
17
|
+
this.eventBus.removeListener('dequeue', listener);
|
|
18
|
+
resolve(null);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.eventBus.on('dequeue', listener);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await fn();
|
|
27
|
+
|
|
28
|
+
this.queue.shift();
|
|
29
|
+
this.eventBus.emit('dequeue');
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -362,14 +362,14 @@ ${JSON.stringify(refresh, null, 2)}
|
|
|
362
362
|
([key1], [key2]) => this.parsedSettings.statefulParameterOrder.get(key1)! - this.parsedSettings.statefulParameterOrder.get(key2)!
|
|
363
363
|
)
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
await Promise.all(sortedEntries.map(async ([key, desiredValue]) => {
|
|
366
366
|
const statefulParameter = this.parsedSettings.statefulParameters.get(key);
|
|
367
367
|
if (!statefulParameter) {
|
|
368
368
|
throw new Error(`Stateful parameter ${key} was not found`);
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
(result as Record<string, unknown>)[key] = await statefulParameter.refresh(desiredValue ?? null, allParameters)
|
|
372
|
-
}
|
|
372
|
+
}))
|
|
373
373
|
|
|
374
374
|
return result;
|
|
375
375
|
}
|
package/tsconfig.json
CHANGED