impel-cli 0.18.17 → 0.19.0
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 +62 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/commands/pat.js +2 -2
- package/src/commands/remote.js +559 -0
- package/src/remote/aws.js +142 -0
- package/src/remote/process.js +131 -0
- package/src/remote/state.js +202 -0
- package/src/remote/transfer.js +293 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { runCapture, runInteractive, sleep } from "./process.js";
|
|
2
|
+
|
|
3
|
+
function awsPrefix({ region, profile }) {
|
|
4
|
+
return [
|
|
5
|
+
...(region ? ["--region", region] : []),
|
|
6
|
+
...(profile ? ["--profile", profile] : []),
|
|
7
|
+
];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function awsJson(context, args) {
|
|
11
|
+
const result = runCapture(process.env.IMPEL_REMOTE_AWS_BIN || "aws", [...awsPrefix(context), ...args, "--output", "json"]);
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(result.stdout);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`AWS CLI returned invalid JSON for ${args.slice(0, 2).join(" ")}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readStack(context) {
|
|
20
|
+
const payload = awsJson(context, ["cloudformation", "describe-stacks", "--stack-name", context.stackName]);
|
|
21
|
+
const stack = payload?.Stacks?.[0];
|
|
22
|
+
if (!stack || !Array.isArray(stack.Outputs)) throw new Error(`CloudFormation stack ${context.stackName} has no outputs`);
|
|
23
|
+
const outputs = Object.fromEntries(stack.Outputs.map((entry) => [entry.OutputKey, entry.OutputValue]));
|
|
24
|
+
for (const name of ["ClusterName", "TaskDefinitionArn", "PublicSubnetIds", "RunnerSecurityGroupId"]) {
|
|
25
|
+
if (!outputs[name]) throw new Error(`CloudFormation stack ${context.stackName} is missing output ${name}`);
|
|
26
|
+
}
|
|
27
|
+
return outputs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function startTask(context, { outputs, publicKey, ttlSeconds, runId }) {
|
|
31
|
+
const networkConfiguration = JSON.stringify({
|
|
32
|
+
awsvpcConfiguration: {
|
|
33
|
+
subnets: outputs.PublicSubnetIds.split(",").filter(Boolean),
|
|
34
|
+
securityGroups: [outputs.RunnerSecurityGroupId],
|
|
35
|
+
assignPublicIp: "ENABLED",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const overrides = JSON.stringify({
|
|
39
|
+
containerOverrides: [{
|
|
40
|
+
name: "runner",
|
|
41
|
+
environment: [
|
|
42
|
+
{ name: "IMPEL_REMOTE_SSH_PUBLIC_KEY", value: publicKey.trim() },
|
|
43
|
+
{ name: "IMPEL_REMOTE_TTL_SECONDS", value: String(ttlSeconds) },
|
|
44
|
+
{ name: "IMPEL_REMOTE_RUN_ID", value: runId },
|
|
45
|
+
],
|
|
46
|
+
}],
|
|
47
|
+
});
|
|
48
|
+
const payload = awsJson(context, [
|
|
49
|
+
"ecs", "run-task",
|
|
50
|
+
"--cluster", outputs.ClusterName,
|
|
51
|
+
"--task-definition", outputs.TaskDefinitionArn,
|
|
52
|
+
"--launch-type", "FARGATE",
|
|
53
|
+
"--platform-version", "LATEST",
|
|
54
|
+
"--enable-execute-command",
|
|
55
|
+
"--network-configuration", networkConfiguration,
|
|
56
|
+
"--overrides", overrides,
|
|
57
|
+
"--tags", `key=useimpel:purpose,value=remote-session`, `key=useimpel:run-id,value=${runId}`,
|
|
58
|
+
]);
|
|
59
|
+
const task = payload?.tasks?.[0];
|
|
60
|
+
if (!task?.taskArn) {
|
|
61
|
+
const reason = payload?.failures?.[0]?.reason || "ECS returned no task";
|
|
62
|
+
throw new Error(`could not start remote runner: ${reason}`);
|
|
63
|
+
}
|
|
64
|
+
return { taskArn: task.taskArn, clusterName: outputs.ClusterName };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function describeTask(context, state) {
|
|
68
|
+
const payload = awsJson(context, [
|
|
69
|
+
"ecs", "describe-tasks",
|
|
70
|
+
"--cluster", state.aws.clusterName,
|
|
71
|
+
"--tasks", state.aws.taskArn,
|
|
72
|
+
]);
|
|
73
|
+
const task = payload?.tasks?.[0];
|
|
74
|
+
if (!task) throw new Error(`ECS task ${state.aws.taskArn} was not found`);
|
|
75
|
+
const container = task.containers?.find((candidate) => candidate.name === "runner") || task.containers?.[0] || {};
|
|
76
|
+
const executeAgent = container.managedAgents?.find((candidate) => candidate.name === "ExecuteCommandAgent");
|
|
77
|
+
const taskId = String(state.aws.taskArn).split("/").at(-1);
|
|
78
|
+
return {
|
|
79
|
+
lastStatus: task.lastStatus || "UNKNOWN",
|
|
80
|
+
desiredStatus: task.desiredStatus || "UNKNOWN",
|
|
81
|
+
stoppedReason: task.stoppedReason || null,
|
|
82
|
+
exitCode: Number.isInteger(container.exitCode) ? container.exitCode : null,
|
|
83
|
+
containerReason: container.reason || null,
|
|
84
|
+
runtimeId: container.runtimeId || null,
|
|
85
|
+
executeAgentStatus: executeAgent?.lastStatus || null,
|
|
86
|
+
taskId,
|
|
87
|
+
ssmTarget: container.runtimeId
|
|
88
|
+
? `ecs:${state.aws.clusterName}_${taskId}_${container.runtimeId}`
|
|
89
|
+
: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function waitForTaskReady(context, state, timeoutSeconds) {
|
|
94
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
95
|
+
let latest;
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
latest = describeTask(context, state);
|
|
98
|
+
if (
|
|
99
|
+
latest.lastStatus === "RUNNING"
|
|
100
|
+
&& latest.executeAgentStatus === "RUNNING"
|
|
101
|
+
&& latest.runtimeId
|
|
102
|
+
) return latest;
|
|
103
|
+
if (latest.lastStatus === "STOPPED") {
|
|
104
|
+
throw new Error(`remote runner stopped during startup: ${latest.stoppedReason || latest.containerReason || `exit ${latest.exitCode ?? "unknown"}`}`);
|
|
105
|
+
}
|
|
106
|
+
await sleep(Number(process.env.IMPEL_REMOTE_POLL_MS || 3000));
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`remote runner did not become ready within ${timeoutSeconds} seconds`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function stopTask(context, state, reason = "Impel remote session stopped") {
|
|
112
|
+
if (!state?.aws?.taskArn || !state?.aws?.clusterName) return false;
|
|
113
|
+
runCapture(process.env.IMPEL_REMOTE_AWS_BIN || "aws", [
|
|
114
|
+
...awsPrefix(context),
|
|
115
|
+
"ecs", "stop-task",
|
|
116
|
+
"--cluster", state.aws.clusterName,
|
|
117
|
+
"--task", state.aws.taskArn,
|
|
118
|
+
"--reason", reason,
|
|
119
|
+
"--output", "json",
|
|
120
|
+
]);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function startSshProxy(context, state, port) {
|
|
125
|
+
const target = state?.aws?.ssmTarget;
|
|
126
|
+
if (!target) throw new Error(`remote run ${state.runId} has no ready SSM target`);
|
|
127
|
+
return runInteractive(process.env.IMPEL_REMOTE_AWS_BIN || "aws", [
|
|
128
|
+
...awsPrefix(context),
|
|
129
|
+
"ssm", "start-session",
|
|
130
|
+
"--target", target,
|
|
131
|
+
"--document-name", "AWS-StartSSHSession",
|
|
132
|
+
"--parameters", `portNumber=${port}`,
|
|
133
|
+
], { allowFailure: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function awsContext(stateOrOptions) {
|
|
137
|
+
return {
|
|
138
|
+
region: stateOrOptions?.region || stateOrOptions?.aws?.region || "eu-west-2",
|
|
139
|
+
profile: stateOrOptions?.profile || stateOrOptions?.aws?.profile || null,
|
|
140
|
+
stackName: stateOrOptions?.stackName || stateOrOptions?.aws?.stackName || "impel-remote-dev",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
import { redactSecretText } from "../config.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export class RemoteProcessError extends Error {
|
|
10
|
+
constructor(command, args, result) {
|
|
11
|
+
const rendered = [command, ...args].map((value) => JSON.stringify(String(value))).join(" ");
|
|
12
|
+
const detail = redactSecretText(result?.stderr || result?.error?.message || "command failed").trim();
|
|
13
|
+
super(`${rendered} failed${detail ? `: ${detail}` : ""}`);
|
|
14
|
+
this.command = command;
|
|
15
|
+
this.args = [...args];
|
|
16
|
+
this.status = Number.isInteger(result?.status) ? result.status : 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findExecutable(command, environment = process.env) {
|
|
21
|
+
if (path.isAbsolute(command)) {
|
|
22
|
+
try {
|
|
23
|
+
fs.accessSync(command, fs.constants.X_OK);
|
|
24
|
+
return command;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const pathValue = environment.PATH || "";
|
|
30
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
31
|
+
if (!directory) continue;
|
|
32
|
+
const candidate = path.join(directory, command);
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
35
|
+
return candidate;
|
|
36
|
+
} catch {
|
|
37
|
+
// Keep searching PATH.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requireExecutables(commands, environment = process.env) {
|
|
44
|
+
const missing = commands.filter((command) => !findExecutable(command, environment));
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
throw new Error(`required command${missing.length === 1 ? " is" : "s are"} not installed: ${missing.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function runCapture(command, args = [], {
|
|
51
|
+
cwd,
|
|
52
|
+
env = process.env,
|
|
53
|
+
input,
|
|
54
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
55
|
+
allowFailure = false,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const result = spawnSync(command, args, {
|
|
58
|
+
cwd,
|
|
59
|
+
env,
|
|
60
|
+
input,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
maxBuffer,
|
|
63
|
+
windowsHide: true,
|
|
64
|
+
});
|
|
65
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
66
|
+
throw new RemoteProcessError(command, args, result);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
70
|
+
stdout: result.stdout || "",
|
|
71
|
+
stderr: result.stderr || "",
|
|
72
|
+
error: result.error || null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function runBuffer(command, args = [], {
|
|
77
|
+
cwd,
|
|
78
|
+
env = process.env,
|
|
79
|
+
input,
|
|
80
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
81
|
+
allowFailure = false,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const result = spawnSync(command, args, {
|
|
84
|
+
cwd,
|
|
85
|
+
env,
|
|
86
|
+
input,
|
|
87
|
+
encoding: null,
|
|
88
|
+
maxBuffer,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
});
|
|
91
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
92
|
+
throw new RemoteProcessError(command, args, {
|
|
93
|
+
...result,
|
|
94
|
+
stderr: result.stderr?.toString("utf8") || "",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
99
|
+
stdout: result.stdout || Buffer.alloc(0),
|
|
100
|
+
stderr: result.stderr || Buffer.alloc(0),
|
|
101
|
+
error: result.error || null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function runInteractive(command, args = [], {
|
|
106
|
+
cwd,
|
|
107
|
+
env = process.env,
|
|
108
|
+
inputFile,
|
|
109
|
+
allowFailure = false,
|
|
110
|
+
} = {}) {
|
|
111
|
+
let descriptor;
|
|
112
|
+
try {
|
|
113
|
+
if (inputFile) descriptor = fs.openSync(inputFile, "r");
|
|
114
|
+
const result = spawnSync(command, args, {
|
|
115
|
+
cwd,
|
|
116
|
+
env,
|
|
117
|
+
stdio: [descriptor ?? "inherit", "inherit", "inherit"],
|
|
118
|
+
windowsHide: false,
|
|
119
|
+
});
|
|
120
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
121
|
+
throw new RemoteProcessError(command, args, result);
|
|
122
|
+
}
|
|
123
|
+
return Number.isInteger(result.status) ? result.status : 1;
|
|
124
|
+
} finally {
|
|
125
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sleep(milliseconds) {
|
|
130
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
131
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { CONFIG_DIR } from "../config.js";
|
|
7
|
+
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
8
|
+
import { renameWithWindowsRetry } from "../windowsFs.js";
|
|
9
|
+
|
|
10
|
+
export const REMOTE_STATE_VERSION = 1;
|
|
11
|
+
export const REMOTE_ROOT = process.env.IMPEL_REMOTE_STATE_DIR || path.join(CONFIG_DIR, "remote");
|
|
12
|
+
export const REMOTE_RUNS_DIR = path.join(REMOTE_ROOT, "runs");
|
|
13
|
+
export const SSH_CONFIG_PATH = process.env.IMPEL_REMOTE_SSH_CONFIG || path.join(os.homedir(), ".ssh", "config");
|
|
14
|
+
|
|
15
|
+
const RUN_ID_RE = /^run_[a-z0-9]{16}$/u;
|
|
16
|
+
const SSH_START_PREFIX = "# >>> impel remote ";
|
|
17
|
+
const SSH_END_PREFIX = "# <<< impel remote ";
|
|
18
|
+
|
|
19
|
+
function lstatOrNull(target) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.lstatSync(target);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error?.code === "ENOENT") return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensurePrivateDirectory(directory) {
|
|
29
|
+
const existing = lstatOrNull(directory);
|
|
30
|
+
if (existing?.isSymbolicLink()) throw new Error(`${directory} must not be a symbolic link`);
|
|
31
|
+
if (existing && !existing.isDirectory()) throw new Error(`${directory} must be a directory`);
|
|
32
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
33
|
+
try { fs.chmodSync(directory, 0o700); } catch { /* Best effort on Windows. */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function atomicWrite(filePath, contents, mode = 0o600) {
|
|
37
|
+
ensurePrivateDirectory(path.dirname(filePath));
|
|
38
|
+
const existing = lstatOrNull(filePath);
|
|
39
|
+
if (existing?.isSymbolicLink()) throw new Error(`${filePath} must not be a symbolic link`);
|
|
40
|
+
if (existing && !existing.isFile()) throw new Error(`${filePath} must be a regular file`);
|
|
41
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(temporaryPath, contents, { mode });
|
|
44
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
45
|
+
try { fs.chmodSync(filePath, mode); } catch { /* Best effort on Windows. */ }
|
|
46
|
+
} finally {
|
|
47
|
+
try { fs.rmSync(temporaryPath, { force: true }); } catch { /* Rename removed it. */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newRunId() {
|
|
52
|
+
return `run_${crypto.randomBytes(10).toString("hex").slice(0, 16)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateRunId(runId) {
|
|
56
|
+
const normalized = String(runId || "").trim();
|
|
57
|
+
if (!RUN_ID_RE.test(normalized)) throw new Error(`invalid remote run id ${JSON.stringify(normalized)}`);
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function runDirectory(runId) {
|
|
62
|
+
return path.join(REMOTE_RUNS_DIR, validateRunId(runId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runPaths(runId) {
|
|
66
|
+
const root = runDirectory(runId);
|
|
67
|
+
return {
|
|
68
|
+
root,
|
|
69
|
+
state: path.join(root, "run.json"),
|
|
70
|
+
privateKey: path.join(root, "id_ed25519"),
|
|
71
|
+
publicKey: path.join(root, "id_ed25519.pub"),
|
|
72
|
+
knownHosts: path.join(root, "known_hosts"),
|
|
73
|
+
bundle: path.join(root, "repo.bundle"),
|
|
74
|
+
fileList: path.join(root, "files.list"),
|
|
75
|
+
archive: path.join(root, "working-tree.tar"),
|
|
76
|
+
deletedList: path.join(root, "deleted.list"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function initializeRun(runId) {
|
|
81
|
+
const paths = runPaths(runId);
|
|
82
|
+
ensurePrivateDirectory(paths.root);
|
|
83
|
+
return paths;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeRunState(state) {
|
|
87
|
+
const runId = validateRunId(state?.runId);
|
|
88
|
+
const next = { ...state, schemaVersion: REMOTE_STATE_VERSION, updatedAt: new Date().toISOString() };
|
|
89
|
+
atomicWrite(runPaths(runId).state, `${JSON.stringify(next, null, 2)}\n`);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readRunState(runId) {
|
|
94
|
+
const statePath = runPaths(runId).state;
|
|
95
|
+
let value;
|
|
96
|
+
try {
|
|
97
|
+
value = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error?.code === "ENOENT") throw new Error(`remote run ${runId} does not exist`);
|
|
100
|
+
throw new Error(`${statePath} is not a valid remote run state file`);
|
|
101
|
+
}
|
|
102
|
+
if (value?.schemaVersion !== REMOTE_STATE_VERSION || value?.runId !== runId) {
|
|
103
|
+
throw new Error(`${statePath} has an unsupported remote run state version`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function listRunStates() {
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(REMOTE_RUNS_DIR, { withFileTypes: true });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code === "ENOENT") return [];
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const states = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isDirectory() || !RUN_ID_RE.test(entry.name)) continue;
|
|
119
|
+
try { states.push(readRunState(entry.name)); } catch { /* A corrupt run is not selected implicitly. */ }
|
|
120
|
+
}
|
|
121
|
+
return states.sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveRunId(requested, { includeStopped = false } = {}) {
|
|
125
|
+
if (requested) return validateRunId(requested);
|
|
126
|
+
const current = listRunStates().find((state) => includeStopped || state.status !== "stopped");
|
|
127
|
+
if (!current) throw new Error("no active remote run found; start one with `impel remote up`");
|
|
128
|
+
return current.runId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function sshAlias(runId) {
|
|
132
|
+
return `impel-${validateRunId(runId).replaceAll("_", "-")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sshQuote(value) {
|
|
136
|
+
const text = String(value);
|
|
137
|
+
if (/[\r\n\0]/u.test(text)) throw new Error("unsafe value in managed SSH configuration");
|
|
138
|
+
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function removeManagedBlock(text, runId) {
|
|
142
|
+
const startMarker = `${SSH_START_PREFIX}${runId} >>>`;
|
|
143
|
+
const endMarker = `${SSH_END_PREFIX}${runId} <<<`;
|
|
144
|
+
const start = text.indexOf(startMarker);
|
|
145
|
+
if (start === -1) return text;
|
|
146
|
+
const end = text.indexOf(endMarker, start + startMarker.length);
|
|
147
|
+
if (end === -1) throw new Error(`${SSH_CONFIG_PATH} has an incomplete Impel remote block for ${runId}`);
|
|
148
|
+
const after = end + endMarker.length;
|
|
149
|
+
return `${text.slice(0, start).trimEnd()}${text.slice(after).trimStart() ? `\n${text.slice(after).trimStart()}` : ""}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function installSshAlias(state) {
|
|
153
|
+
const { runId } = state;
|
|
154
|
+
const paths = runPaths(runId);
|
|
155
|
+
const alias = state.alias || sshAlias(runId);
|
|
156
|
+
const current = fs.existsSync(SSH_CONFIG_PATH) ? fs.readFileSync(SSH_CONFIG_PATH, "utf8") : "";
|
|
157
|
+
const preserved = removeManagedBlock(current, runId).trimEnd();
|
|
158
|
+
const proxyTokens = [process.execPath, IMPEL_CLI_ENTRYPOINT, "remote", "proxy", runId, "--port", "%p"];
|
|
159
|
+
const block = [
|
|
160
|
+
`${SSH_START_PREFIX}${runId} >>>`,
|
|
161
|
+
`Host ${alias}`,
|
|
162
|
+
` HostName ${runId}.remote.impel.invalid`,
|
|
163
|
+
" User agent",
|
|
164
|
+
" Port 22",
|
|
165
|
+
` IdentityFile ${sshQuote(paths.privateKey)}`,
|
|
166
|
+
" IdentitiesOnly yes",
|
|
167
|
+
" BatchMode yes",
|
|
168
|
+
" StrictHostKeyChecking accept-new",
|
|
169
|
+
` UserKnownHostsFile ${sshQuote(paths.knownHosts)}`,
|
|
170
|
+
` ProxyCommand ${proxyTokens.map(sshQuote).join(" ")}`,
|
|
171
|
+
`${SSH_END_PREFIX}${runId} <<<`,
|
|
172
|
+
].join("\n");
|
|
173
|
+
// OpenSSH keeps the first value it obtains for most settings. Put concrete
|
|
174
|
+
// Impel hosts before user wildcard blocks so a preceding `Host *` cannot
|
|
175
|
+
// replace this run's user, hostname, key, or ProxyCommand.
|
|
176
|
+
atomicWrite(SSH_CONFIG_PATH, `${block}${preserved ? `\n\n${preserved}` : ""}\n`);
|
|
177
|
+
return alias;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function removeSshAlias(runId) {
|
|
181
|
+
if (!fs.existsSync(SSH_CONFIG_PATH)) return false;
|
|
182
|
+
const current = fs.readFileSync(SSH_CONFIG_PATH, "utf8");
|
|
183
|
+
const next = removeManagedBlock(current, validateRunId(runId));
|
|
184
|
+
if (next === current) return false;
|
|
185
|
+
atomicWrite(SSH_CONFIG_PATH, next.trim() ? `${next.trimEnd()}\n` : "");
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function removeRunSecrets(runId) {
|
|
190
|
+
const paths = runPaths(runId);
|
|
191
|
+
for (const target of [
|
|
192
|
+
paths.privateKey,
|
|
193
|
+
paths.publicKey,
|
|
194
|
+
paths.knownHosts,
|
|
195
|
+
paths.bundle,
|
|
196
|
+
paths.fileList,
|
|
197
|
+
paths.archive,
|
|
198
|
+
paths.deletedList,
|
|
199
|
+
]) {
|
|
200
|
+
try { fs.rmSync(target, { force: true }); } catch { /* Best-effort local cleanup. */ }
|
|
201
|
+
}
|
|
202
|
+
}
|