Add SSH launcher and cached remote runtime
This commit is contained in:
@@ -32,7 +32,30 @@ export interface IRemoteServerBootstrapOptions {
|
||||
nodeEnv?: string;
|
||||
}
|
||||
|
||||
export const defaultInstallRoot = '~/.git.zone/ide-server';
|
||||
export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions {
|
||||
runtimeRoot: string;
|
||||
ideDataRoot?: string;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralReadinessOptions {
|
||||
runtimeRoot: string;
|
||||
theiaPort: number;
|
||||
ideDataRoot?: string;
|
||||
nodePath?: string;
|
||||
waitSeconds?: number;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralRuntimeCacheCheckOptions {
|
||||
runtimeRoot: string;
|
||||
runtimeSha256: string;
|
||||
markerFileName?: string;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export const defaultIdeDataRoot = '~/.git.zone/ide';
|
||||
export const defaultInstallRoot = '~/.git.zone/ide/server';
|
||||
export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256';
|
||||
|
||||
export const createRemoteServerInstallPlan = (
|
||||
options: IRemoteServerInstallPlanOptions,
|
||||
@@ -70,12 +93,12 @@ export const createRemoteInstallCommand = (plan: IRemoteServerInstallPlan) => {
|
||||
const manifestJson = JSON.stringify(plan.manifest, undefined, 2);
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.logsDir)}`,
|
||||
`cat > ${quoteShellArg(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
|
||||
`mkdir -p ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.logsDir)}`,
|
||||
`cat > ${quoteRemotePath(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
|
||||
manifestJson,
|
||||
'GITZONE_IDE_MANIFEST',
|
||||
`ln -sfn ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.currentLink)}`,
|
||||
`touch ${quoteShellArg(plan.markerFile)}`,
|
||||
`ln -sfn ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.currentLink)}`,
|
||||
`touch ${quoteRemotePath(plan.markerFile)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -97,16 +120,95 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteShellArg(plan.paths.logsDir)}`,
|
||||
`test -d ${quoteShellArg(options.workspacePath)}`,
|
||||
`cd ${quoteShellArg(options.workspacePath)}`,
|
||||
...Object.entries(env).map(([key, value]) => `export ${key}=${quoteShellArg(value)}`),
|
||||
`nohup pnpm --dir ${quoteShellArg(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteShellArg(options.workspacePath)} > ${quoteShellArg(logFile)} 2>&1 < /dev/null &`,
|
||||
`mkdir -p ${quoteRemotePath(plan.paths.logsDir)}`,
|
||||
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
|
||||
`test -d ${quoteRemotePath(appDir)} || { printf 'remote Theia app not installed: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
|
||||
'command -v pnpm >/dev/null || { printf \'pnpm not found on remote host\n\' >&2; exit 1; }',
|
||||
`cd ${quoteRemotePath(options.workspacePath)}`,
|
||||
...Object.entries(env).map(([key, value]) => {
|
||||
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
|
||||
return `export ${key}=${renderedValue}`;
|
||||
}),
|
||||
`nohup pnpm --dir ${quoteRemotePath(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'theiaPort=%s\\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralBootstrapOptions) => {
|
||||
const appDir = joinRemotePath(options.runtimeRoot, 'applications/remote-theia');
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const logsDir = joinRemotePath(ideDataRoot, 'logs');
|
||||
const theiaConfigDir = joinRemotePath(ideDataRoot, 'theia');
|
||||
const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`);
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`,
|
||||
`test -x ${quoteRemotePath(nodePath)} || { printf 'bundled node not executable: %s\n' ${quoteShellArg(nodePath)} >&2; exit 1; }`,
|
||||
`test -f ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} || { printf 'bundled Theia backend missing: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
|
||||
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
|
||||
`cd ${quoteRemotePath(options.workspacePath)}`,
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export THEIA_CONFIG_DIR=${quoteRemotePath(theiaConfigDir)}`,
|
||||
...Object.entries(env).map(([key, value]) => {
|
||||
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
|
||||
return `export ${key}=${renderedValue}`;
|
||||
}),
|
||||
`nohup ${quoteRemotePath(nodePath)} ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'runtimeRoot=%s\n' ${quoteShellArg(options.runtimeRoot)}`,
|
||||
`printf 'theiaPort=%s\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralReadinessCommand = (options: IRemoteEphemeralReadinessOptions) => {
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const logFile = joinRemotePath(ideDataRoot, 'logs', `theia-${options.theiaPort}.log`);
|
||||
const waitSeconds = options.waitSeconds ?? 30;
|
||||
const probeScript = `fetch('http://127.0.0.1:${options.theiaPort}/').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))`;
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`for attempt in $(seq 1 ${waitSeconds}); do`,
|
||||
` if ${quoteRemotePath(nodePath)} -e ${quoteShellArg(probeScript)} >/dev/null 2>&1; then`,
|
||||
` printf 'theiaReady=%s\n' ${options.theiaPort}`,
|
||||
' exit 0',
|
||||
' fi',
|
||||
' sleep 1',
|
||||
'done',
|
||||
`printf 'remote Theia did not become ready on port %s\n' ${options.theiaPort} >&2`,
|
||||
`if test -f ${quoteRemotePath(logFile)}; then sed -n '1,200p' ${quoteRemotePath(logFile)} >&2; fi`,
|
||||
'exit 1',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralRuntimeCacheCheckCommand = (options: IRemoteEphemeralRuntimeCacheCheckOptions) => {
|
||||
const markerFileName = options.markerFileName ?? remoteEphemeralRuntimeMarkerFileName;
|
||||
const markerPath = joinRemotePath(options.runtimeRoot, markerFileName);
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const backendPath = joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/main.js');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`test -f ${quoteRemotePath(markerPath)}`,
|
||||
`test "$(cat ${quoteRemotePath(markerPath)})" = ${quoteShellArg(options.runtimeSha256)}`,
|
||||
`test -x ${quoteRemotePath(nodePath)}`,
|
||||
`test -f ${quoteRemotePath(backendPath)}`,
|
||||
`printf 'runtimeCache=hit\n'`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => {
|
||||
const plan = createRemoteServerInstallPlan({
|
||||
serverVersion,
|
||||
@@ -115,8 +217,8 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
|
||||
});
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`test -f ${quoteShellArg(plan.markerFile)}`,
|
||||
`cat ${quoteShellArg(plan.paths.manifestPath)}`,
|
||||
`test -f ${quoteRemotePath(plan.markerFile)}`,
|
||||
`cat ${quoteRemotePath(plan.paths.manifestPath)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -128,6 +230,22 @@ export const quoteShellArg = (value: string | number | boolean) => {
|
||||
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
|
||||
};
|
||||
|
||||
export const quoteRemotePath = (value: string | number | boolean) => {
|
||||
const stringValue = String(value);
|
||||
if (stringValue === '~' || stringValue === '$HOME' || stringValue === '${HOME}') {
|
||||
return '"$HOME"';
|
||||
}
|
||||
|
||||
for (const prefix of ['~/', '$HOME/', '${HOME}/']) {
|
||||
if (stringValue.startsWith(prefix)) {
|
||||
const suffix = stringValue.slice(prefix.length);
|
||||
return suffix ? `"$HOME"/${quoteShellArg(suffix)}` : '"$HOME"';
|
||||
}
|
||||
}
|
||||
|
||||
return quoteShellArg(stringValue);
|
||||
};
|
||||
|
||||
export const joinRemotePath = (...parts: string[]) => {
|
||||
const [first, ...rest] = parts.filter(Boolean);
|
||||
if (!first) {
|
||||
|
||||
Reference in New Issue
Block a user