fix: make deploy ssh identity yaml first
This commit is contained in:
+29
-1
@@ -10,7 +10,7 @@ import { join, posix as posixPath } from "node:path";
|
||||
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog";
|
||||
import { runCommand } from "../command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "../config";
|
||||
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
|
||||
import { deploySshIdentityPlan, ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
|
||||
import { jobWithTail, listJobs, readJob, startJob } from "../jobs";
|
||||
import { coreInternalFetch } from "../microservices";
|
||||
import {
|
||||
@@ -61,6 +61,34 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
|
||||
};
|
||||
}
|
||||
if (action === "status") return status(ciTarget(providerIdOption(args)));
|
||||
if (action === "github-ssh-identity") {
|
||||
const subAction = nameArg ?? "plan";
|
||||
const target = providerIdOption(args);
|
||||
const identityId = stringOption(args, "--identity") ?? stringOption(args, "--identity-id");
|
||||
if (subAction === "plan") return deploySshIdentityPlan(target, identityId);
|
||||
if (subAction === "ensure") {
|
||||
if (!boolFlag(args, "--confirm")) {
|
||||
const confirmArgs = [
|
||||
"bun scripts/cli.ts ci github-ssh-identity ensure",
|
||||
target === null ? "" : `--target ${target}`,
|
||||
identityId === null ? "" : `--identity ${identityId}`,
|
||||
"--confirm",
|
||||
].filter((item) => item.length > 0).join(" ");
|
||||
return {
|
||||
ok: false,
|
||||
action: "deploy-ssh-identity-ensure",
|
||||
mutation: false,
|
||||
mode: "confirm-required",
|
||||
plan: deploySshIdentityPlan(target, identityId),
|
||||
next: {
|
||||
confirm: confirmArgs,
|
||||
},
|
||||
};
|
||||
}
|
||||
return await ensureGithubSshIdentityForProvider(config, target ?? "", identityId);
|
||||
}
|
||||
throw new Error("ci github-ssh-identity must be one of: plan, ensure");
|
||||
}
|
||||
if (action === "run") {
|
||||
const target = ciTarget(providerIdOption(args));
|
||||
const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk";
|
||||
|
||||
@@ -51,7 +51,7 @@ export function catalogArtifactDescriptor(artifact: CiCatalogArtifact): Record<s
|
||||
export function ciHelp(): Record<string, unknown> {
|
||||
const catalog = loadCiCatalog();
|
||||
return {
|
||||
command: "ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs|cleanup-runs|cleanup-failed-pods",
|
||||
command: "ci install|status|github-ssh-identity|run|publish-backend-core|publish-user-service|run-dev-e2e|logs|cleanup-runs|cleanup-failed-pods",
|
||||
description: "Manage native k3s Tekton CI on D601 or G14. CI may publish commit-pinned image artifacts, but it intentionally does not deploy CD.",
|
||||
examples: [
|
||||
"bun scripts/cli.ts ci install",
|
||||
@@ -60,6 +60,8 @@ export function ciHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts ci install-status latest",
|
||||
"bun scripts/cli.ts ci install --wait --skip-prewarm --skip-tekton-install",
|
||||
"bun scripts/cli.ts ci plan --target D601",
|
||||
"bun scripts/cli.ts ci github-ssh-identity plan --target D518",
|
||||
"bun scripts/cli.ts ci github-ssh-identity ensure --target D518 --confirm",
|
||||
"bun scripts/cli.ts ci install --target G14",
|
||||
"bun scripts/cli.ts ci run --revision <commit>",
|
||||
"bun scripts/cli.ts ci run --target G14 --revision <commit>",
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { runSshCommandCapture } from "./ssh";
|
||||
|
||||
export interface GithubSshIdentityDistribution {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
fingerprint: string | null;
|
||||
seededFromLocal: boolean;
|
||||
identityId?: string;
|
||||
targetId?: string;
|
||||
configTruth?: Record<string, unknown>;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
@@ -18,13 +23,230 @@ interface GithubSshIdentity {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const identityId = "github.com";
|
||||
const defaultPrivateKeyPath = "/root/.ssh/id_ed25519";
|
||||
const defaultKnownHostsPath = "/root/.ssh/known_hosts";
|
||||
const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789";
|
||||
interface DeploySshSourceRef {
|
||||
sourceRef: string;
|
||||
sourceKey: "file";
|
||||
}
|
||||
|
||||
function providerHomeDir(providerId: string): string {
|
||||
return providerId === "G14" ? "/root" : "/home/ubuntu";
|
||||
interface DeploySshCreateFromLocal {
|
||||
enabled: boolean;
|
||||
privateKeyPath: string;
|
||||
publicKeyPath: string;
|
||||
knownHostsPath: string;
|
||||
}
|
||||
|
||||
interface DeploySshIdentitySpec {
|
||||
id: string;
|
||||
host: string;
|
||||
privateKey: DeploySshSourceRef;
|
||||
publicKey: DeploySshSourceRef;
|
||||
knownHosts: DeploySshSourceRef;
|
||||
createFromLocal: DeploySshCreateFromLocal | null;
|
||||
}
|
||||
|
||||
interface DeploySshTargetSpec {
|
||||
targetId: string;
|
||||
providerId: string;
|
||||
route: string;
|
||||
homeDir: string;
|
||||
egress: DeploySshEgressSpec;
|
||||
identities: string[];
|
||||
}
|
||||
|
||||
type DeploySshEgressSpec =
|
||||
| { mode: "direct" }
|
||||
| { mode: "http-connect-proxy"; proxyUrl: string };
|
||||
|
||||
interface DeploySshIdentityConfig {
|
||||
configPath: string;
|
||||
sourceRoot: string;
|
||||
defaults: {
|
||||
targetId: string;
|
||||
identityId: string;
|
||||
};
|
||||
identities: Record<string, DeploySshIdentitySpec>;
|
||||
targets: Record<string, DeploySshTargetSpec>;
|
||||
}
|
||||
|
||||
interface ResolvedDeploySshIdentitySourcePaths {
|
||||
privateKeyPath: string;
|
||||
publicKeyPath: string;
|
||||
knownHostsPath: string;
|
||||
}
|
||||
|
||||
interface SourceGithubIdentityResult {
|
||||
identity: GithubSshIdentity;
|
||||
seededFromLocal: boolean;
|
||||
}
|
||||
|
||||
const deploySshIdentitiesConfigPath = "config/deploy-ssh-identities.yaml";
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown, path: string): Record<string, unknown> | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
return asRecord(value, path);
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function absolutePathField(record: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(record, key, path);
|
||||
if (!isAbsolute(value)) throw new Error(`${path}.${key} must be an absolute path`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(record: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArrayField(record: Record<string, unknown>, key: string, path: string): string[] {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
|
||||
throw new Error(`${path}.${key} must be a non-empty string array`);
|
||||
}
|
||||
return value.map((item) => item.trim());
|
||||
}
|
||||
|
||||
function sourceRefField(record: Record<string, unknown>, key: string, path: string): DeploySshSourceRef {
|
||||
const value = asRecord(record[key], `${path}.${key}`);
|
||||
const sourceRef = stringField(value, "sourceRef", `${path}.${key}`);
|
||||
const sourceKey = stringField(value, "sourceKey", `${path}.${key}`);
|
||||
if (sourceKey !== "file") throw new Error(`${path}.${key}.sourceKey must be file`);
|
||||
if (isAbsolute(sourceRef) || sourceRef.split(/[\\/]+/u).includes("..")) {
|
||||
throw new Error(`${path}.${key}.sourceRef must be a relative path under sources.root`);
|
||||
}
|
||||
return { sourceRef, sourceKey };
|
||||
}
|
||||
|
||||
function parseDeploySshCreateFromLocal(value: unknown, path: string): DeploySshCreateFromLocal | null {
|
||||
const record = optionalRecord(value, path);
|
||||
if (record === null) return null;
|
||||
return {
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
privateKeyPath: absolutePathField(record, "privateKeyPath", path),
|
||||
publicKeyPath: absolutePathField(record, "publicKeyPath", path),
|
||||
knownHostsPath: absolutePathField(record, "knownHostsPath", path),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDeploySshIdentity(id: string, value: unknown): DeploySshIdentitySpec {
|
||||
const path = `${deploySshIdentitiesConfigPath}#identities.${id}`;
|
||||
const record = asRecord(value, path);
|
||||
return {
|
||||
id,
|
||||
host: stringField(record, "host", path),
|
||||
privateKey: sourceRefField(record, "privateKey", path),
|
||||
publicKey: sourceRefField(record, "publicKey", path),
|
||||
knownHosts: sourceRefField(record, "knownHosts", path),
|
||||
createFromLocal: parseDeploySshCreateFromLocal(record.createFromLocal, `${path}.createFromLocal`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDeploySshTarget(targetId: string, value: unknown): DeploySshTargetSpec {
|
||||
const path = `${deploySshIdentitiesConfigPath}#targets.${targetId}`;
|
||||
const record = asRecord(value, path);
|
||||
const egress = parseDeploySshEgress(record.egress, `${path}.egress`);
|
||||
return {
|
||||
targetId,
|
||||
providerId: stringField(record, "providerId", path),
|
||||
route: stringField(record, "route", path),
|
||||
homeDir: absolutePathField(record, "homeDir", path),
|
||||
egress,
|
||||
identities: stringArrayField(record, "identities", path),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDeploySshEgress(value: unknown, path: string): DeploySshEgressSpec {
|
||||
const record = asRecord(value, path);
|
||||
const mode = stringField(record, "mode", path);
|
||||
if (mode === "direct") return { mode };
|
||||
if (mode === "http-connect-proxy") return { mode, proxyUrl: stringField(record, "proxyUrl", path) };
|
||||
throw new Error(`${path}.mode must be direct or http-connect-proxy`);
|
||||
}
|
||||
|
||||
function loadDeploySshIdentityConfig(): DeploySshIdentityConfig {
|
||||
const parsed = Bun.YAML.parse(readFileSync(rootPath(deploySshIdentitiesConfigPath), "utf8")) as unknown;
|
||||
const root = asRecord(parsed, deploySshIdentitiesConfigPath);
|
||||
if (root.version !== 1) throw new Error(`${deploySshIdentitiesConfigPath}.version must be 1`);
|
||||
if (root.kind !== "UnideskDeploySshIdentities") throw new Error(`${deploySshIdentitiesConfigPath}.kind must be UnideskDeploySshIdentities`);
|
||||
const defaults = asRecord(root.defaults, `${deploySshIdentitiesConfigPath}.defaults`);
|
||||
const sources = asRecord(root.sources, `${deploySshIdentitiesConfigPath}.sources`);
|
||||
const identities = asRecord(root.identities, `${deploySshIdentitiesConfigPath}.identities`);
|
||||
const targets = asRecord(root.targets, `${deploySshIdentitiesConfigPath}.targets`);
|
||||
const config: DeploySshIdentityConfig = {
|
||||
configPath: deploySshIdentitiesConfigPath,
|
||||
sourceRoot: absolutePathField(sources, "root", `${deploySshIdentitiesConfigPath}.sources`),
|
||||
defaults: {
|
||||
targetId: stringField(defaults, "targetId", `${deploySshIdentitiesConfigPath}.defaults`),
|
||||
identityId: stringField(defaults, "identityId", `${deploySshIdentitiesConfigPath}.defaults`),
|
||||
},
|
||||
identities: Object.fromEntries(Object.entries(identities).map(([id, value]) => [id, parseDeploySshIdentity(id, value)])),
|
||||
targets: Object.fromEntries(Object.entries(targets).map(([id, value]) => [id, parseDeploySshTarget(id, value)])),
|
||||
};
|
||||
if (config.targets[config.defaults.targetId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}.defaults.targetId references missing target ${config.defaults.targetId}`);
|
||||
if (config.identities[config.defaults.identityId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}.defaults.identityId references missing identity ${config.defaults.identityId}`);
|
||||
for (const target of Object.values(config.targets)) {
|
||||
for (const identityId of target.identities) {
|
||||
if (config.identities[identityId] === undefined) throw new Error(`${deploySshIdentitiesConfigPath}#targets.${target.targetId}.identities references missing identity ${identityId}`);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function selectDeploySshTarget(config: DeploySshIdentityConfig, selection: string | null | undefined): { target: DeploySshTargetSpec; defaulted: boolean } {
|
||||
const raw = selection === undefined || selection === null || selection.length === 0 ? config.defaults.targetId : selection;
|
||||
const target = Object.values(config.targets).find((candidate) => candidate.targetId.toLowerCase() === raw.toLowerCase() || candidate.providerId.toLowerCase() === raw.toLowerCase());
|
||||
if (target === undefined) throw new Error(`${config.configPath} has no target ${raw}; known targets: ${Object.keys(config.targets).join(", ")}`);
|
||||
return { target, defaulted: raw === config.defaults.targetId && (selection === undefined || selection === null || selection.length === 0) };
|
||||
}
|
||||
|
||||
function selectDeploySshIdentity(config: DeploySshIdentityConfig, target: DeploySshTargetSpec, selection: string | null | undefined): { identity: DeploySshIdentitySpec; defaulted: boolean } {
|
||||
const raw = selection === undefined || selection === null || selection.length === 0 ? config.defaults.identityId : selection;
|
||||
const identity = config.identities[raw];
|
||||
if (identity === undefined) throw new Error(`${config.configPath} has no identity ${raw}; known identities: ${Object.keys(config.identities).join(", ")}`);
|
||||
if (!target.identities.includes(identity.id)) throw new Error(`${config.configPath}#targets.${target.targetId}.identities does not include ${identity.id}`);
|
||||
return { identity, defaulted: raw === config.defaults.identityId && (selection === undefined || selection === null || selection.length === 0) };
|
||||
}
|
||||
|
||||
function sourceFilePath(config: DeploySshIdentityConfig, sourceRef: string): string {
|
||||
const sourceRoot = resolve(config.sourceRoot);
|
||||
const resolved = resolve(sourceRoot, sourceRef);
|
||||
const rel = relative(sourceRoot, resolved);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`${deploySshIdentitiesConfigPath} sourceRef escapes sources.root: ${sourceRef}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function sourcePaths(config: DeploySshIdentityConfig, identity: DeploySshIdentitySpec): ResolvedDeploySshIdentitySourcePaths {
|
||||
return {
|
||||
privateKeyPath: sourceFilePath(config, identity.privateKey.sourceRef),
|
||||
publicKeyPath: sourceFilePath(config, identity.publicKey.sourceRef),
|
||||
knownHostsPath: sourceFilePath(config, identity.knownHosts.sourceRef),
|
||||
};
|
||||
}
|
||||
|
||||
function configTruth(config: DeploySshIdentityConfig, target: DeploySshTargetSpec, identity: DeploySshIdentitySpec, targetDefaulted: boolean, identityDefaulted: boolean): Record<string, unknown> {
|
||||
return {
|
||||
configPath: config.configPath,
|
||||
sourceRoot: config.sourceRoot,
|
||||
targetPath: `${config.configPath}#targets.${target.targetId}`,
|
||||
identityPath: `${config.configPath}#identities.${identity.id}`,
|
||||
defaults: {
|
||||
targetPath: `${config.configPath}#defaults.targetId`,
|
||||
identityPath: `${config.configPath}#defaults.identityId`,
|
||||
targetDefaulted,
|
||||
identityDefaulted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pgLiteral(value: string): string {
|
||||
@@ -91,7 +313,7 @@ function publicKeyFingerprint(publicKey: string): string {
|
||||
return `SHA256:${digest}`;
|
||||
}
|
||||
|
||||
function githubKnownHostLinesFromText(text: string): string[] {
|
||||
function githubKnownHostLinesFromText(text: string, hostName = "github.com"): string[] {
|
||||
const rows: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawLine of text.split(/\r?\n/u)) {
|
||||
@@ -100,7 +322,7 @@ function githubKnownHostLinesFromText(text: string): string[] {
|
||||
const parts = line.split(/\s+/u);
|
||||
if (parts.length < 3) continue;
|
||||
const hostList = parts[0]?.split(",") ?? [];
|
||||
if (!hostList.some((host) => host === "github.com" || host === "[github.com]:22")) continue;
|
||||
if (!hostList.some((host) => host === hostName || host === `[${hostName}]:22`)) continue;
|
||||
if (seen.has(line)) continue;
|
||||
seen.add(line);
|
||||
rows.push(line);
|
||||
@@ -108,22 +330,23 @@ function githubKnownHostLinesFromText(text: string): string[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function readGithubKnownHosts(): string {
|
||||
const knownHostsPath = process.env.UNIDESK_GITHUB_KNOWN_HOSTS_PATH || defaultKnownHostsPath;
|
||||
const localLines = existsSync(knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(knownHostsPath, "utf8")) : [];
|
||||
function readGithubKnownHosts(knownHostsPath: string, host: string): string {
|
||||
const localLines = existsSync(knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(knownHostsPath, "utf8"), host) : [];
|
||||
if (localLines.length > 0) return `${localLines.join("\n")}\n`;
|
||||
const scan = commandOutput(["ssh-keyscan", "-t", "rsa,ecdsa,ed25519", "github.com"], undefined, 20_000);
|
||||
const scannedLines = githubKnownHostLinesFromText(scan.stdout);
|
||||
if (scannedLines.length === 0) throw new Error(scan.stderr || "failed to collect github.com SSH host keys");
|
||||
const scan = commandOutput(["ssh-keyscan", "-t", "rsa,ecdsa,ed25519", host], undefined, 20_000);
|
||||
const scannedLines = githubKnownHostLinesFromText(scan.stdout, host);
|
||||
if (scannedLines.length === 0) throw new Error(scan.stderr || `failed to collect ${host} SSH host keys`);
|
||||
return `${scannedLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function readLocalGithubIdentity(): GithubSshIdentity | null {
|
||||
const privateKeyPath = process.env.UNIDESK_GITHUB_SSH_KEY_PATH || defaultPrivateKeyPath;
|
||||
function readLocalGithubIdentity(identity: DeploySshIdentitySpec): GithubSshIdentity | null {
|
||||
const local = identity.createFromLocal;
|
||||
if (local === null || !local.enabled) return null;
|
||||
const privateKeyPath = local.privateKeyPath;
|
||||
if (!existsSync(privateKeyPath)) return null;
|
||||
const privateKey = readFileSync(privateKeyPath, "utf8");
|
||||
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error(`invalid private key format: ${privateKeyPath}`);
|
||||
const publicKeyPath = `${privateKeyPath}.pub`;
|
||||
const publicKeyPath = local.publicKeyPath;
|
||||
const publicKey = existsSync(publicKeyPath)
|
||||
? readFileSync(publicKeyPath, "utf8").trim()
|
||||
: commandOutput(["ssh-keygen", "-y", "-f", privateKeyPath]).stdout.trim();
|
||||
@@ -131,24 +354,69 @@ function readLocalGithubIdentity(): GithubSshIdentity | null {
|
||||
return {
|
||||
privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
|
||||
publicKey,
|
||||
knownHosts: readGithubKnownHosts(),
|
||||
knownHosts: readGithubKnownHosts(local.knownHostsPath, identity.host),
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertGithubIdentity(config: UniDeskConfig, identity: GithubSshIdentity): void {
|
||||
function writeSourceFileIfMissing(path: string, value: string, mode: number): boolean {
|
||||
if (existsSync(path)) return false;
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, value, { mode });
|
||||
chmodSync(path, mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureSourceGithubIdentity(config: DeploySshIdentityConfig, identitySpec: DeploySshIdentitySpec): SourceGithubIdentityResult {
|
||||
const paths = sourcePaths(config, identitySpec);
|
||||
let seededFromLocal = false;
|
||||
if (!existsSync(paths.privateKeyPath) || !existsSync(paths.publicKeyPath) || !existsSync(paths.knownHostsPath)) {
|
||||
const localIdentity = readLocalGithubIdentity(identitySpec);
|
||||
if (localIdentity !== null) {
|
||||
const seeded = [
|
||||
writeSourceFileIfMissing(paths.privateKeyPath, localIdentity.privateKey, 0o600),
|
||||
writeSourceFileIfMissing(paths.publicKeyPath, `${localIdentity.publicKey.trim()}\n`, 0o644),
|
||||
writeSourceFileIfMissing(paths.knownHostsPath, localIdentity.knownHosts, 0o600),
|
||||
];
|
||||
seededFromLocal = seeded.some(Boolean);
|
||||
}
|
||||
}
|
||||
if (!existsSync(paths.privateKeyPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey sourceRef is missing`);
|
||||
if (!existsSync(paths.publicKeyPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.publicKey sourceRef is missing`);
|
||||
if (!existsSync(paths.knownHostsPath)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.knownHosts sourceRef is missing`);
|
||||
const privateKey = readFileSync(paths.privateKeyPath, "utf8");
|
||||
const publicKey = readFileSync(paths.publicKeyPath, "utf8").trim();
|
||||
const knownHosts = readFileSync(paths.knownHostsPath, "utf8");
|
||||
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey sourceRef has invalid private key material`);
|
||||
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256)\s+\S+/u.test(publicKey)) throw new Error(`${config.configPath}#identities.${identitySpec.id}.publicKey sourceRef has invalid public key material`);
|
||||
if (githubKnownHostLinesFromText(knownHosts, identitySpec.host).length === 0) throw new Error(`${config.configPath}#identities.${identitySpec.id}.knownHosts sourceRef has no ${identitySpec.host} rows`);
|
||||
const derived = commandOutput(["ssh-keygen", "-y", "-f", paths.privateKeyPath]);
|
||||
if (!derived.ok) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey failed ssh-keygen verification`);
|
||||
if (derived.stdout.trim() !== publicKey) throw new Error(`${config.configPath}#identities.${identitySpec.id}.privateKey does not match publicKey`);
|
||||
return {
|
||||
identity: {
|
||||
privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
|
||||
publicKey,
|
||||
knownHosts: knownHosts.endsWith("\n") ? knownHosts : `${knownHosts}\n`,
|
||||
updatedAt: null,
|
||||
},
|
||||
seededFromLocal,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertGithubIdentity(config: UniDeskConfig, identitySpec: DeploySshIdentitySpec, identity: GithubSshIdentity): void {
|
||||
const fingerprint = publicKeyFingerprint(identity.publicKey);
|
||||
const sql = `
|
||||
${ensureIdentityTableSql()}
|
||||
INSERT INTO unidesk_deploy_ssh_identities (id, host, private_key, public_key, public_key_fingerprint, known_hosts, source, updated_at)
|
||||
VALUES (
|
||||
${pgLiteral(identityId)},
|
||||
${pgLiteral(identityId)},
|
||||
${pgLiteral(identitySpec.id)},
|
||||
${pgLiteral(identitySpec.host)},
|
||||
${pgLiteral(identity.privateKey)},
|
||||
${pgLiteral(identity.publicKey)},
|
||||
${pgLiteral(fingerprint)},
|
||||
${pgLiteral(identity.knownHosts)},
|
||||
'operator-local',
|
||||
${pgLiteral(`${deploySshIdentitiesConfigPath}#identities.${identitySpec.id}`)},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
@@ -164,7 +432,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
if (!result.ok) throw new Error(`failed to upsert GitHub SSH identity in PostgreSQL: ${result.stderr || `exit=${result.exitCode}`}`);
|
||||
}
|
||||
|
||||
function readGithubIdentityFromDatabase(config: UniDeskConfig): GithubSshIdentity {
|
||||
function readGithubIdentityFromDatabase(config: UniDeskConfig, identitySpec: DeploySshIdentitySpec): GithubSshIdentity {
|
||||
const sql = `
|
||||
${ensureIdentityTableSql()}
|
||||
SELECT json_build_object(
|
||||
@@ -174,7 +442,7 @@ SELECT json_build_object(
|
||||
'updatedAt', updated_at
|
||||
)::text
|
||||
FROM unidesk_deploy_ssh_identities
|
||||
WHERE id = ${pgLiteral(identityId)}
|
||||
WHERE id = ${pgLiteral(identitySpec.id)}
|
||||
LIMIT 1;
|
||||
`;
|
||||
const result = runPsql(config, sql);
|
||||
@@ -187,7 +455,7 @@ LIMIT 1;
|
||||
const knownHosts = typeof parsed.knownHosts === "string" ? parsed.knownHosts : "";
|
||||
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(privateKey)) throw new Error("PostgreSQL GitHub SSH identity has invalid private key material");
|
||||
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256)\s+\S+/u.test(publicKey)) throw new Error("PostgreSQL GitHub SSH identity has invalid public key material");
|
||||
if (githubKnownHostLinesFromText(knownHosts).length === 0) throw new Error("PostgreSQL GitHub SSH identity has no github.com known_hosts rows");
|
||||
if (githubKnownHostLinesFromText(knownHosts, identitySpec.host).length === 0) throw new Error(`PostgreSQL GitHub SSH identity has no ${identitySpec.host} known_hosts rows`);
|
||||
return {
|
||||
privateKey: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
|
||||
publicKey,
|
||||
@@ -207,6 +475,7 @@ import sys
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
home_dir = pathlib.Path(str(data.get("homeDir") or os.environ.get("HOME") or "/home/ubuntu"))
|
||||
host = str(data.get("host") or "github.com")
|
||||
ssh_dir = home_dir / ".ssh"
|
||||
private_key = str(data.get("privateKey") or "")
|
||||
public_key = str(data.get("publicKey") or "").strip()
|
||||
@@ -235,8 +504,8 @@ if derived.stdout.strip() != public_key:
|
||||
raise SystemExit("installed private key does not match public key")
|
||||
|
||||
known_hosts_path.touch(mode=0o600, exist_ok=True)
|
||||
subprocess.run(["ssh-keygen", "-R", "github.com", "-f", str(known_hosts_path)], text=True, capture_output=True)
|
||||
subprocess.run(["ssh-keygen", "-R", "[github.com]:22", "-f", str(known_hosts_path)], text=True, capture_output=True)
|
||||
subprocess.run(["ssh-keygen", "-R", host, "-f", str(known_hosts_path)], text=True, capture_output=True)
|
||||
subprocess.run(["ssh-keygen", "-R", f"[{host}]:22", "-f", str(known_hosts_path)], text=True, capture_output=True)
|
||||
existing = known_hosts_path.read_text().splitlines()
|
||||
rows = []
|
||||
seen = set()
|
||||
@@ -321,78 +590,159 @@ while True:
|
||||
`;
|
||||
}
|
||||
|
||||
function distributeGithubIdentity(providerId: string, identity: GithubSshIdentity): { ok: boolean; detail: string; raw: unknown } {
|
||||
async function distributeGithubIdentity(config: UniDeskConfig, target: DeploySshTargetSpec, identitySpec: DeploySshIdentitySpec, identity: GithubSshIdentity): Promise<{ ok: boolean; detail: string; raw: unknown }> {
|
||||
const payload = JSON.stringify({
|
||||
privateKey: identity.privateKey,
|
||||
publicKey: identity.publicKey,
|
||||
knownHosts: identity.knownHosts,
|
||||
homeDir: providerHomeDir(providerId),
|
||||
homeDir: target.homeDir,
|
||||
host: identitySpec.host,
|
||||
});
|
||||
const remotePython = remoteInstallPythonSource();
|
||||
const proxyPython = gitSshHttpConnectProxySource();
|
||||
const remoteCommand = [
|
||||
"set -euo pipefail",
|
||||
`python3 -c ${shellQuote(remotePython)}`,
|
||||
`proxy_url=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
|
||||
"curl -fsSI --max-time 20 -x \"$proxy_url\" https://github.com >/dev/null",
|
||||
`home_dir=${shellQuote(target.homeDir)}`,
|
||||
"export HOME=\"$home_dir\"",
|
||||
`host=${shellQuote(identitySpec.host)}`,
|
||||
`egress_mode=${shellQuote(target.egress.mode)}`,
|
||||
`proxy_url=${target.egress.mode === "http-connect-proxy" ? shellQuote(target.egress.proxyUrl) : "''"}`,
|
||||
"git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py",
|
||||
"cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'",
|
||||
"if [ \"$egress_mode\" = \"http-connect-proxy\" ]; then",
|
||||
" curl -fsSI --max-time 20 -x \"$proxy_url\" \"https://$host\" >/dev/null",
|
||||
"else",
|
||||
" curl -fsSI --max-time 20 \"https://$host\" >/dev/null",
|
||||
"fi",
|
||||
"if [ \"$egress_mode\" = \"http-connect-proxy\" ]; then",
|
||||
" cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'",
|
||||
proxyPython,
|
||||
"UNIDESK_GIT_SSH_PROXY",
|
||||
"chmod 700 \"$git_ssh_proxy\"",
|
||||
"export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"",
|
||||
"export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"",
|
||||
"auth_output=$(ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o \"ProxyCommand=$git_ssh_proxy %h %p\" -T git@github.com 2>&1 || true)",
|
||||
" chmod 700 \"$git_ssh_proxy\"",
|
||||
" export UNIDESK_GIT_SSH_HTTP_PROXY=\"$proxy_url\"",
|
||||
" export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'\"",
|
||||
" auth_output=$(ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o \"ProxyCommand=$git_ssh_proxy %h %p\" -T \"git@$host\" 2>&1 || true)",
|
||||
"else",
|
||||
" export GIT_SSH_COMMAND=\"ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519\"",
|
||||
" auth_output=$(ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -T \"git@$host\" 2>&1 || true)",
|
||||
"fi",
|
||||
"printf '%s\\n' \"$auth_output\"",
|
||||
"printf '%s\\n' \"$auth_output\" | grep -q 'successfully authenticated'",
|
||||
].join("\n");
|
||||
const result = spawnSync(process.execPath, [
|
||||
rootPath("scripts", "cli.ts"),
|
||||
"ssh",
|
||||
providerId,
|
||||
"--",
|
||||
remoteCommand,
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
input: payload,
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
maxBuffer: 1024 * 1024 * 4,
|
||||
env: { ...process.env, UNIDESK_SSH_OPEN_TIMEOUT_MS: process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || "60000" },
|
||||
});
|
||||
const result = await runSshCommandCapture(config, target.route, ["argv", "bash", "-lc", remoteCommand], payload);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? result.error?.message ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
ok: result.exitCode === 0,
|
||||
detail: [stdout, stderr].filter(Boolean).join("\n").slice(-2000),
|
||||
raw: { exitCode: result.status, stdoutTail: stdout.slice(-1200), stderrTail: stderr.slice(-1200) },
|
||||
raw: {
|
||||
targetId: target.targetId,
|
||||
providerId: target.providerId,
|
||||
route: target.route,
|
||||
homeDir: target.homeDir,
|
||||
egress: target.egress,
|
||||
identityId: identitySpec.id,
|
||||
host: identitySpec.host,
|
||||
exitCode: result.exitCode,
|
||||
stdoutTail: stdout.slice(-1200),
|
||||
stderrTail: stderr.slice(-1200),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureGithubSshIdentityForProvider(config: UniDeskConfig, providerId: string): Promise<GithubSshIdentityDistribution> {
|
||||
let seededFromLocal = false;
|
||||
const localIdentity = readLocalGithubIdentity();
|
||||
if (localIdentity !== null) {
|
||||
upsertGithubIdentity(config, localIdentity);
|
||||
seededFromLocal = true;
|
||||
}
|
||||
const identity = readGithubIdentityFromDatabase(config);
|
||||
function deploySshIdentitySourceStatus(config: DeploySshIdentityConfig, identity: DeploySshIdentitySpec): Record<string, unknown> {
|
||||
const paths = sourcePaths(config, identity);
|
||||
const sourceStatus = (kind: string, sourceRef: string, path: string): Record<string, unknown> => {
|
||||
const present = existsSync(path);
|
||||
return {
|
||||
kind,
|
||||
sourceRef,
|
||||
sourceKey: "file",
|
||||
present,
|
||||
bytes: present ? statSync(path).size : 0,
|
||||
};
|
||||
};
|
||||
const publicKey = existsSync(paths.publicKeyPath) ? readFileSync(paths.publicKeyPath, "utf8").trim() : "";
|
||||
return {
|
||||
privateKey: sourceStatus("privateKey", identity.privateKey.sourceRef, paths.privateKeyPath),
|
||||
publicKey: {
|
||||
...sourceStatus("publicKey", identity.publicKey.sourceRef, paths.publicKeyPath),
|
||||
fingerprint: publicKey.length > 0 ? publicKeyFingerprint(publicKey) : null,
|
||||
},
|
||||
knownHosts: {
|
||||
...sourceStatus("knownHosts", identity.knownHosts.sourceRef, paths.knownHostsPath),
|
||||
hostRows: existsSync(paths.knownHostsPath) ? githubKnownHostLinesFromText(readFileSync(paths.knownHostsPath, "utf8"), identity.host).length : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function deploySshIdentityPlan(targetSelection: string | null | undefined, identitySelection: string | null | undefined = null): Record<string, unknown> {
|
||||
const deployConfig = loadDeploySshIdentityConfig();
|
||||
const { target, defaulted: targetDefaulted } = selectDeploySshTarget(deployConfig, targetSelection);
|
||||
const { identity, defaulted: identityDefaulted } = selectDeploySshIdentity(deployConfig, target, identitySelection);
|
||||
const sourceStatus = deploySshIdentitySourceStatus(deployConfig, identity);
|
||||
const allSourcesPresent = [sourceStatus.privateKey, sourceStatus.publicKey, sourceStatus.knownHosts].every((item) => asRecord(item, "sourceStatus").present === true);
|
||||
return {
|
||||
ok: true,
|
||||
action: "deploy-ssh-identity-plan",
|
||||
mutation: false,
|
||||
identity: {
|
||||
id: identity.id,
|
||||
host: identity.host,
|
||||
sourceStatus,
|
||||
createFromLocal: identity.createFromLocal === null ? null : {
|
||||
enabled: identity.createFromLocal.enabled,
|
||||
privateKeyPath: identity.createFromLocal.privateKeyPath,
|
||||
publicKeyPath: identity.createFromLocal.publicKeyPath,
|
||||
knownHostsPath: identity.createFromLocal.knownHostsPath,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
targetId: target.targetId,
|
||||
providerId: target.providerId,
|
||||
route: target.route,
|
||||
homeDir: target.homeDir,
|
||||
egress: target.egress,
|
||||
},
|
||||
ready: allSourcesPresent || identity.createFromLocal?.enabled === true,
|
||||
configTruth: configTruth(deployConfig, target, identity, targetDefaulted, identityDefaulted),
|
||||
valuesRedacted: true,
|
||||
next: {
|
||||
ensure: `bun scripts/cli.ts ci github-ssh-identity ensure --target ${target.targetId} --identity ${identity.id} --confirm`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureGithubSshIdentityForProvider(config: UniDeskConfig, providerId: string, identitySelection: string | null | undefined = null): Promise<GithubSshIdentityDistribution> {
|
||||
const deployConfig = loadDeploySshIdentityConfig();
|
||||
const { target, defaulted: targetDefaulted } = selectDeploySshTarget(deployConfig, providerId);
|
||||
const { identity: identitySpec, defaulted: identityDefaulted } = selectDeploySshIdentity(deployConfig, target, identitySelection);
|
||||
const source = ensureSourceGithubIdentity(deployConfig, identitySpec);
|
||||
upsertGithubIdentity(config, identitySpec, source.identity);
|
||||
const identity = readGithubIdentityFromDatabase(config, identitySpec);
|
||||
const fingerprint = publicKeyFingerprint(identity.publicKey);
|
||||
const distribution = distributeGithubIdentity(providerId, identity);
|
||||
const distribution = await distributeGithubIdentity(config, target, identitySpec, identity);
|
||||
if (!distribution.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `failed to distribute GitHub SSH identity from PostgreSQL to ${providerId}: ${distribution.detail || "remote ssh failed"}`,
|
||||
detail: `failed to distribute GitHub SSH identity from YAML sourceRef via PostgreSQL to ${target.targetId}: ${distribution.detail || "remote ssh failed"}`,
|
||||
fingerprint,
|
||||
seededFromLocal,
|
||||
seededFromLocal: source.seededFromLocal,
|
||||
identityId: identitySpec.id,
|
||||
targetId: target.targetId,
|
||||
configTruth: configTruth(deployConfig, target, identitySpec, targetDefaulted, identityDefaulted),
|
||||
raw: distribution.raw,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
detail: `GitHub SSH identity distributed from PostgreSQL to ${providerId}; fingerprint=${fingerprint}; seededFromLocal=${seededFromLocal}`,
|
||||
detail: `GitHub SSH identity distributed from YAML sourceRef via PostgreSQL to ${target.targetId}; fingerprint=${fingerprint}; seededFromLocal=${source.seededFromLocal}`,
|
||||
fingerprint,
|
||||
seededFromLocal,
|
||||
seededFromLocal: source.seededFromLocal,
|
||||
identityId: identitySpec.id,
|
||||
targetId: target.targetId,
|
||||
configTruth: configTruth(deployConfig, target, identitySpec, targetDefaulted, identityDefaulted),
|
||||
raw: distribution.raw,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user