Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 135 additions & 5 deletions bin/helpers/db-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync, spawn } from 'child_process';
import { execSync, execFileSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';

Expand Down Expand Up @@ -70,7 +70,8 @@ export function getInfisicalSecrets(config: InfisicalConfig, token: string, envi

// ─── Database URL parsing ───────────────────────────────────────────────────
export function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
// 容忍驱动后缀(postgresql+asyncpg:// 等)
const match = url.match(/^postgresql(?:\+\w+)?:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
if (!match) throw new Error('Failed to parse DATABASE_URL (format: postgresql://user:pass@host:port/db)');
return { user: decodeURIComponent(match[1]), password: decodeURIComponent(match[2]), host: match[3], port: parseInt(match[4], 10), database: match[5] };
}
Expand Down Expand Up @@ -189,6 +190,131 @@ export function setupTunnel(dbHost: string, localPort: number): void {
}
}

// ─── cn-prod (阿里云) ─────────────────────────────────────────────────────────
// cn-prod 跑在阿里云 SAE,与海外 AWS 完全独立:独立 Infisical (secrets-cn.optima.chat)
// + 内网 RDS(无公网端点)经 buildbox ECS 跳板 SSH 隧道访问。设计见 optima-dev-skills#21。
const CN_INFISICAL_URL = process.env.INFISICAL_CN_URL || 'https://secrets-cn.optima.chat';
const CN_INFISICAL_ORG = process.env.INFISICAL_CN_ORG || 'f44012fa-0659-4f7e-b0ac-ed01244efc8a';
const CN_INFISICAL_PROJECT = process.env.INFISICAL_CN_PROJECT || '4deef229-11be-40a5-8f56-b61f0bce7240';
const CN_RDS_HOST = 'pgm-2zexwx9eso9e4yla.pg.rds.aliyuncs.com';
const CN_BUILDBOX_HOST = process.env.OPTIMA_CN_BUILDBOX_HOST || '47.94.105.163';

/** env 是否指向 cn-prod(阿里云)。接受 `cn` / `cn-prod`。 */
export function isCnEnv(env: string): boolean {
return env === 'cn' || env === 'cn-prod';
}

/** curl → JSON,用 execFileSync 传参(避免 shell 引号坑,跨平台安全)。 */
function curlJson(args: string[]): any {
const out = execFileSync('curl', ['-s', ...args], { encoding: 'utf-8' });
try { return JSON.parse(out || '{}'); }
catch { throw new Error(`cn Infisical: non-JSON response: ${String(out).slice(0, 200)}`); }
}

/**
* 认证 cn Infisical(admin email/password → org-scoped token)。
* ⚠️ 字段名坑:login 返回 `accessToken`(不是 token),select-organization 才返回 `token`。
*/
export function getCnInfisicalToken(): string {
const email = process.env.INFISICAL_CN_EMAIL;
const password = process.env.INFISICAL_CN_PASSWORD;
if (!email || !password) {
throw new Error('cn Infisical 需要 INFISICAL_CN_EMAIL + INFISICAL_CN_PASSWORD 环境变量(admin user,1P "Infisical cn-prod admin (secrets-cn.optima.chat)")。见 optima-dev-skills#21。');
}
const login = curlJson([
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/login`,
'-H', 'Content-Type: application/json',
'-d', JSON.stringify({ email, password }),
]);
if (!login.accessToken) throw new Error(`cn Infisical login 失败: ${JSON.stringify(login).slice(0, 200)}`);
const org = curlJson([
'-X', 'POST', `${CN_INFISICAL_URL}/api/v3/auth/select-organization`,
'-H', 'Content-Type: application/json',
'-H', `Authorization: Bearer ${login.accessToken}`,
'-d', JSON.stringify({ organizationId: CN_INFISICAL_ORG }),
]);
if (!org.token) throw new Error(`cn Infisical select-organization 失败: ${JSON.stringify(org).slice(0, 200)}`);
return org.token;
}

/** 读 cn Infisical 某 folder(prod env)→ key→value map。expand=true 让服务端解析 `${...}` 引用。 */
export function getCnSecrets(token: string, secretPath: string, expand = false): Record<string, string> {
const data = curlJson([
`${CN_INFISICAL_URL}/api/v3/secrets/raw?workspaceId=${CN_INFISICAL_PROJECT}&environment=prod&secretPath=${encodeURIComponent(secretPath)}&expandSecretReferences=${expand}`,
'-H', `Authorization: Bearer ${token}`,
]);
const secrets: Record<string, string> = {};
for (const s of data.secrets || []) secrets[s.secretKey] = s.secretValue;
return secrets;
}

/** 隧道 localhost:localPort → cn 内网 RDS,经 buildbox ECS 跳板(SSH)。warm-reuse 同 SSM 路。 */
export function setupCnTunnel(dbHost: string, localPort: number): void {
let portInUse = false;
try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); portInUse = true; } catch { /* free */ }
if (portInUse) {
if (isTunnelHealthy(localPort)) return;
console.log(`! cn tunnel on port ${localPort} not responding (zombie), replacing...`);
killOrphanTunnel(localPort);
}

const pw = process.env.OPTIMA_CN_BUILDBOX_PASSWORD;
if (!pw) throw new Error('cn DB 隧道需要 OPTIMA_CN_BUILDBOX_PASSWORD 环境变量(buildbox root 密码,1P "Aliyun cn-prod buildbox ECS (root)")。见 optima-dev-skills#21。');
try { execSync('command -v sshpass', { stdio: 'ignore' }); }
catch { throw new Error('sshpass not found — cn buildbox 隧道需要(apt install sshpass / brew install esolitos/ipa/sshpass)。Windows 用 WSL。'); }

console.log(`Creating cn tunnel: localhost:${localPort} -> buildbox ${CN_BUILDBOX_HOST} -> ${dbHost}:5432`);
execSync(
`sshpass -p '${pw.replace(/'/g, "'\\''")}' ssh -f -N -o StrictHostKeyChecking=no ` +
`-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -o ConnectTimeout=12 ` +
`-L ${localPort}:${dbHost}:5432 root@${CN_BUILDBOX_HOST}`,
{ stdio: 'inherit' }
);

const deadlineMs = Date.now() + 20000;
while (Date.now() < deadlineMs) {
if (isTunnelHealthy(localPort)) { console.log(`✓ cn tunnel ready on port ${localPort}`); return; }
sleepSync(500);
}
throw new Error(`cn tunnel on port ${localPort} did not become ready within 20s`);
}

/**
* 连 cn-prod 某服务库:creds 取自 cn Infisical 的 /shared-secrets/database-users +
* /database-names(按 prefix,如 AUTH/BILLING),经 buildbox 跳板隧道。返回 query 闭包。
*/
export function connectCnDB(prefix: string, localPort: number): { query: (sql: string) => string } {
const token = getCnInfisicalToken();
const users = getCnSecrets(token, '/shared-secrets/database-users');
const names = getCnSecrets(token, '/shared-secrets/database-names');
const user = users[`${prefix}_DB_USER`];
const password = users[`${prefix}_DB_PASSWORD`];
const database = names[`${prefix}_DB_NAME`];
if (!user || !password || !database) {
throw new Error(`cn DB creds 不全 for ${prefix}(需 ${prefix}_DB_USER/PASSWORD@database-users + ${prefix}_DB_NAME@database-names)。该服务 cn 可能用字面 DATABASE_URL,见 optima-dev-skills#21。`);
}
setupCnTunnel(CN_RDS_HOST, localPort);
const conn: DBConnection = { host: 'localhost', port: localPort, user, password, database };
return { query: (sql: string) => queryDB(conn, sql) };
}

/**
* 连 cn-prod 某服务库,creds 来自该服务 /services/<svc> 的字面/展开 DATABASE_URL。
* 用于 cred 不在 shared-secrets/database-users 的服务(如 gateway-core)。
* expandSecretReferences=true 让 cn Infisical 解析 `${...}` 引用为字面值。
*/
export function connectCnDBFromUrl(servicePath: string, localPort: number): { query: (sql: string) => string } {
const token = getCnInfisicalToken();
const secrets = getCnSecrets(token, servicePath, true);
const dbUrl = secrets['DATABASE_URL'];
if (!dbUrl) throw new Error(`cn DATABASE_URL 未找到 at ${servicePath}`);
if (dbUrl.includes('${')) throw new Error(`cn DATABASE_URL 仍含未解析引用(expand 失败)at ${servicePath}: ${dbUrl.slice(0, 80)}`);
const parsed = parseDatabaseUrl(dbUrl); // host = cn 内网 RDS(经 buildbox 隧道到达)
setupCnTunnel(parsed.host, localPort);
const conn: DBConnection = { host: 'localhost', port: localPort, user: parsed.user, password: parsed.password, database: parsed.database };
return { query: (sql: string) => queryDB(conn, sql) };
}

// ─── psql ───────────────────────────────────────────────────────────────────
function findPsqlPath(): string {
try {
Expand All @@ -211,7 +337,9 @@ export function queryDB(conn: DBConnection, sql: string): string {
// ─── High-level connection helpers ──────────────────────────────────────────

/** Connect to user-auth DB and return a query function. */
export async function connectAuthDB(env: string, infisicalConfig: InfisicalConfig, token: string): Promise<{ query: (sql: string) => string }> {
export async function connectAuthDB(env: string, infisicalConfig?: InfisicalConfig, token?: string): Promise<{ query: (sql: string) => string }> {
if (isCnEnv(env)) return connectCnDB('AUTH', 15436);
if (!infisicalConfig || !token) throw new Error('connectAuthDB: infisicalConfig + token required for AWS envs');
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/shared-secrets/database-users');
const host = RDS_HOSTS[env as 'stage' | 'prod'];
Expand All @@ -224,7 +352,9 @@ export async function connectAuthDB(env: string, infisicalConfig: InfisicalConfi
}

/** Connect to billing DB and return a query function. */
export async function connectBillingDB(env: string, infisicalConfig: InfisicalConfig, token: string): Promise<{ query: (sql: string) => string }> {
export async function connectBillingDB(env: string, infisicalConfig?: InfisicalConfig, token?: string): Promise<{ query: (sql: string) => string }> {
if (isCnEnv(env)) return connectCnDB('BILLING', 15437);
if (!infisicalConfig || !token) throw new Error('connectBillingDB: infisicalConfig + token required for AWS envs');
const infisicalEnv = env === 'stage' ? 'staging' : 'prod';
const secrets = getInfisicalSecrets(infisicalConfig, token, infisicalEnv, '/services/billing');
const dbUrl = secrets['DATABASE_URL'];
Expand All @@ -239,7 +369,7 @@ export async function connectBillingDB(env: string, infisicalConfig: InfisicalCo
}

/** Look up user_id by email from user-auth DB. Throws if not found. */
export async function resolveUserId(email: string, env: string, infisicalConfig: InfisicalConfig, token: string): Promise<string> {
export async function resolveUserId(email: string, env: string, infisicalConfig?: InfisicalConfig, token?: string): Promise<string> {
console.log(`Looking up user by email: ${email}`);
const auth = await connectAuthDB(env, infisicalConfig, token);
const userId = auth.query(`SELECT id FROM users WHERE email='${escapeSQL(email)}' LIMIT 1`);
Expand Down
13 changes: 7 additions & 6 deletions bin/helpers/grant-balance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL, isCnEnv } from './db-utils';

function parseArgs(args: string[]): { email: string; amountUsd: number; description: string | null; env: string } {
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
Expand All @@ -12,7 +12,7 @@ Used for promotional grants, compensation, referral rewards, etc.
Options:
--amount <usd> USD amount to grant (required, e.g. 5 for $5.00)
--description <text> Description for audit trail (optional)
--env <env> Environment: stage, prod (default: stage)
--env <env> Environment: stage, prod, cn (default: stage)
-h, --help Show this help

Examples:
Expand All @@ -36,8 +36,8 @@ Examples:
console.error('--amount is required and must be > 0 (USD)');
process.exit(1);
}
if (!['stage', 'prod'].includes(env)) {
console.error('Env must be stage or prod (billing DB not available in CI)');
if (!['stage', 'prod', 'cn', 'cn-prod'].includes(env)) {
console.error('Env must be stage, prod, or cn (billing DB not available in CI)');
process.exit(1);
}

Expand All @@ -46,8 +46,9 @@ Examples:

async function main() {
const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
const infisicalConfig = getInfisicalConfig();
const token = getInfisicalToken(infisicalConfig);
const isCn = isCnEnv(env);
const infisicalConfig = isCn ? undefined : getInfisicalConfig();
const token = isCn ? undefined : getInfisicalToken(infisicalConfig!);

// 1 USD = 1,000,000 micros. Round to integer micros.
const amountMicros = Math.round(amountUsd * 1_000_000);
Expand Down
11 changes: 6 additions & 5 deletions bin/helpers/grant-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL, isCnEnv } from './db-utils';

function parseArgs(args: string[]): { email: string; plan: string; months: number; env: string } {
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
Expand All @@ -9,7 +9,7 @@ function parseArgs(args: string[]): { email: string; plan: string; months: numbe
Options:
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
--months <n> Duration in months (default: 1)
--env <env> Environment: stage, prod (default: stage)
--env <env> Environment: stage, prod, cn (default: stage)
-h, --help Show this help`);
process.exit(0);
}
Expand All @@ -30,15 +30,16 @@ Options:
process.exit(1);
}
if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
if (!['stage', 'prod'].includes(env)) { console.error('Env must be stage or prod (billing DB not available in CI)'); process.exit(1); }
if (!['stage', 'prod', 'cn', 'cn-prod'].includes(env)) { console.error('Env must be stage, prod, or cn (billing DB not available in CI)'); process.exit(1); }

return { email, plan, months, env };
}

async function main() {
const { email, plan, months, env } = parseArgs(process.argv.slice(2));
const infisicalConfig = getInfisicalConfig();
const token = getInfisicalToken(infisicalConfig);
const isCn = isCnEnv(env);
const infisicalConfig = isCn ? undefined : getInfisicalConfig();
const token = isCn ? undefined : getInfisicalToken(infisicalConfig!);

console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);

Expand Down
24 changes: 22 additions & 2 deletions bin/helpers/query-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { execSync } from 'child_process';
import * as fs from 'fs';
import { setupTunnel } from './db-utils';
import { setupTunnel, connectCnDB, connectCnDBFromUrl, isCnEnv } from './db-utils';

interface InfisicalConfig {
url: string;
Expand Down Expand Up @@ -210,7 +210,7 @@ async function main() {
console.error('Usage: query-db.ts <service> <sql> [environment]');
console.error('');
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, gateway-core, optima-logistics, billing, ads-backend, amazon-backend, browser-backend, shopify-backend, optima-generation, optima-sentinel');
console.error('Environments: ci (default), stage, prod');
console.error('Environments: ci (default), stage, prod, cn (阿里云 cn-prod)');
console.error('');
console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
process.exit(1);
Expand All @@ -224,6 +224,26 @@ async function main() {
process.exit(1);
}

// cn-prod(阿里云):独立 Infisical + 经 buildbox 跳板连内网 RDS。
// 两类 cred:① shared-secrets/database-users(按 prefix)② 服务自己的 DATABASE_URL(展开引用)。
if (isCnEnv(environment)) {
const prodCfg = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP].prod as any;
let db: { query: (sql: string) => string };
if (prodCfg?.userKey) {
const prefix = prodCfg.userKey.replace(/_DB_USER$/, '');
console.log(`\n🔍 Querying ${service} (CN-PROD, prefix ${prefix})...`);
db = connectCnDB(prefix, 15438);
} else if (prodCfg?.databaseUrlPath) {
console.log(`\n🔍 Querying ${service} (CN-PROD, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
db = connectCnDBFromUrl(prodCfg.databaseUrlPath, 15438);
} else {
console.error(`cn-prod query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
process.exit(1);
}
console.log('\n' + db.query(sql));
return;
}

const serviceConfig = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP][environment as 'ci' | 'stage' | 'prod'];

if (!serviceConfig) {
Expand Down