feat(v2): full spec implementation — root SSH, bootstrap sentinel, HMAC webhook, all parametric routes fixed #1
@@ -233,7 +233,7 @@ async function firstDeploy(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`);
|
await appendLog(previewId, `[PP] SSH connected. Waiting for bootstrap to complete...\n`);
|
||||||
await waitForBootstrap(sshSession, 600_000);
|
await waitForBootstrap(sshSession, 900_000);
|
||||||
await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
|
await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
|
||||||
|
|
||||||
if (repoConfig.aptPackages.length > 0) {
|
if (repoConfig.aptPackages.length > 0) {
|
||||||
|
|||||||
+25
-19
@@ -115,7 +115,9 @@ export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BOOTSTRAP_SCRIPT = `#!/bin/bash
|
const BOOTSTRAP_SCRIPT = `#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
exec > >(tee -a /var/log/pp-bootstrap.log) 2>&1
|
||||||
|
trap 'touch /var/lib/pp-bootstrap-failed' ERR
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
apt-get install -y curl git unzip build-essential
|
apt-get install -y curl git unzip build-essential
|
||||||
|
|
||||||
@@ -125,17 +127,6 @@ systemctl enable docker
|
|||||||
systemctl start docker
|
systemctl start docker
|
||||||
apt-get install -y docker-compose-plugin
|
apt-get install -y docker-compose-plugin
|
||||||
|
|
||||||
# Allow root SSH with key auth (sshd_config may default to prohibit-password or no)
|
|
||||||
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin without-password/' /etc/ssh/sshd_config
|
|
||||||
# Copy ubuntu's authorized_keys to root so PP can SSH as root
|
|
||||||
mkdir -p /root/.ssh
|
|
||||||
chmod 700 /root/.ssh
|
|
||||||
if [ -f /home/ubuntu/.ssh/authorized_keys ]; then
|
|
||||||
cp /home/ubuntu/.ssh/authorized_keys /root/.ssh/authorized_keys
|
|
||||||
chmod 600 /root/.ssh/authorized_keys
|
|
||||||
fi
|
|
||||||
systemctl reload sshd || service ssh reload
|
|
||||||
|
|
||||||
# NVM + Node LTS (installed as root, available to root SSH sessions)
|
# NVM + Node LTS (installed as root, available to root SSH sessions)
|
||||||
export HOME=/root
|
export HOME=/root
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
@@ -144,8 +135,18 @@ source "$NVM_DIR/nvm.sh"
|
|||||||
nvm install --lts
|
nvm install --lts
|
||||||
nvm alias default lts/*
|
nvm alias default lts/*
|
||||||
|
|
||||||
# Signal that bootstrap is complete
|
# Do not permit PP's root SSH session until every bootstrap step succeeded.
|
||||||
|
# That prevents PP from seeing SSH as ready while cloud-init is still mutating
|
||||||
|
# the host and turns the completion marker into a reliable readiness signal.
|
||||||
touch /var/lib/pp-bootstrap-done
|
touch /var/lib/pp-bootstrap-done
|
||||||
|
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin without-password/' /etc/ssh/sshd_config
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
if [ -f /home/ubuntu/.ssh/authorized_keys ]; then
|
||||||
|
cp /home/ubuntu/.ssh/authorized_keys /root/.ssh/authorized_keys
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
fi
|
||||||
|
systemctl reload sshd || service ssh reload
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function launchInstance(opts: {
|
export async function launchInstance(opts: {
|
||||||
@@ -179,12 +180,17 @@ export async function launchInstance(opts: {
|
|||||||
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
|
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < maxWaitMs) {
|
while (Date.now() - start < maxWaitMs) {
|
||||||
const res = await ec2.send(new DescribeInstancesCommand({
|
try {
|
||||||
InstanceIds: [instanceId],
|
const res = await ec2.send(new DescribeInstancesCommand({
|
||||||
}));
|
InstanceIds: [instanceId],
|
||||||
const inst = res.Reservations?.[0]?.Instances?.[0];
|
}));
|
||||||
if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
|
const inst = res.Reservations?.[0]?.Instances?.[0];
|
||||||
return inst.PublicIpAddress;
|
if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
|
||||||
|
return inst.PublicIpAddress;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// AWS eventual consistency: instance may not be visible immediately after RunInstances
|
||||||
|
if (e.name !== "InvalidInstanceID.NotFound") throw e;
|
||||||
}
|
}
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,22 +63,32 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis
|
|||||||
username: "root",
|
username: "root",
|
||||||
privateKey,
|
privateKey,
|
||||||
readyTimeout: timeoutMs,
|
readyTimeout: timeoutMs,
|
||||||
|
keepaliveInterval: 10000,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"],
|
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXEC_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`SSH exec timed out: ${command.slice(0, 60)}`)), EXEC_TIMEOUT_MS);
|
||||||
|
|
||||||
conn.exec(command, (err, stream) => {
|
conn.exec(command, (err, stream) => {
|
||||||
if (err) return reject(err);
|
if (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
stream.on("data", (d: Buffer) => { stdout += d.toString(); });
|
stream.on("data", (d: Buffer) => { stdout += d.toString(); });
|
||||||
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
||||||
stream.on("close", (code: number) => {
|
stream.on("close", (code: number) => {
|
||||||
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code: code ?? 0 });
|
resolve({ stdout, stderr, code: code ?? 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -88,8 +98,12 @@ function execOnConn(conn: Client, command: string): Promise<{ stdout: string; st
|
|||||||
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
|
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < maxWaitMs) {
|
while (Date.now() - start < maxWaitMs) {
|
||||||
const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting");
|
try {
|
||||||
if (res.stdout.trim() === "done") return;
|
const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting");
|
||||||
|
if (res.stdout.trim() === "done") return;
|
||||||
|
} catch {
|
||||||
|
// transient SSH exec error (e.g. timeout, channel reset) — retry
|
||||||
|
}
|
||||||
await sleep(10_000);
|
await sleep(10_000);
|
||||||
}
|
}
|
||||||
throw new Error("EC2 bootstrap did not complete within timeout");
|
throw new Error("EC2 bootstrap did not complete within timeout");
|
||||||
|
|||||||
Reference in New Issue
Block a user