44b915554b
壓測 §7.2:seed 通了但 creds push/push/runtime 全卡「缺少 api_key」——
self-hosted init 從不發 api_key,但三條路徑都建在多租戶 {api_key}:cred 模型上。
richblack 拍板:self-hosted 不需祕密 api_key,只需 namespace(分區標籤):
- config:ENV_MAP 加 NAMESPACE/ENCRYPTION_KEY + .env 自動載入(無 dotenv 依賴)
- namespace 明碼用戶自填(.env NAMESPACE=leo),沿用 api_key 路徑 → 零分叉
- encryption_key 用戶 .env 自填(工具不生成不 hash),須與 worker secret 一致
- creds/push/init:缺值改引導設 .env,不再叫去 register
- runtime:cypher 加 POST /webhooks/named/:ns/:name/trigger(namespace 走 path,
公開表單免 header);與 header 路徑共用 triggerNamed,不分叉
- push:self-hosted 顯示 path-based 公開 webhook URL
誠實限制:namespace 明碼非密碼;防外部呼叫靠 webhook 保護(mindset §6)。
CLI 1.3.0 → 1.3.1。SDD: self-hosted-init.md §7.7。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
6.6 KiB
TypeScript
162 lines
6.6 KiB
TypeScript
/**
|
||
* acr push <workflow.yaml>
|
||
*
|
||
* 解析 workflow.yaml,透過 /cypher/search 取得執行圖,
|
||
* 然後 POST 至 cypher.arcrun.dev/webhooks/named(帶 X-Arcrun-API-Key)。
|
||
* Server 以 {api_key}:wf:{name} 為 KV key 存入 WEBHOOKS KV。
|
||
*
|
||
* 不再需要用戶的 CF API Token 或 KV Namespace ID。
|
||
*/
|
||
import chalk from 'chalk';
|
||
import ora from 'ora';
|
||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||
|
||
export async function cmdPush(filePath: string): Promise<void> {
|
||
const config = loadConfig();
|
||
|
||
if (config.mode === 'local') {
|
||
console.error(chalk.red('Local 模式不支援 acr push(Webhook 部署需要 Standard 模式)。'));
|
||
console.log(chalk.gray('請執行 acr init 取得 API Key,或直接用 acr run 本機測試。'));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!config.api_key) {
|
||
if (config.mode === 'self-hosted') {
|
||
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
|
||
console.log(chalk.gray('在專案 .env 設一行(明碼即可):'));
|
||
console.log(chalk.cyan(' NAMESPACE=leo'));
|
||
} else {
|
||
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
// 解析 YAML
|
||
const spinner = ora('解析 workflow.yaml').start();
|
||
let workflow;
|
||
try {
|
||
workflow = loadWorkflowYaml(filePath);
|
||
const triplets = parseTriplets(workflow.flow);
|
||
validateRelations(triplets);
|
||
spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`);
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(`解析失敗:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const executorUrl = getCypherExecutorUrl(config);
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
'X-Arcrun-API-Key': config.api_key,
|
||
};
|
||
|
||
// 向 /cypher/search 取得執行圖
|
||
const searchSpinner = ora('取得執行圖').start();
|
||
let graph: unknown;
|
||
try {
|
||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({ triplets: workflow.flow }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
searchSpinner.fail(chalk.red(`執行圖解析失敗(${res.status}):${err.slice(0, 200)}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
|
||
if (data.missing?.length > 0) {
|
||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||
process.exit(1);
|
||
}
|
||
|
||
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
||
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
|
||
const cfg = (workflow.config ?? {}) as Record<string, Record<string, unknown>>;
|
||
|
||
const nodes = rawGraph.nodes.map(node => {
|
||
const nodeCfg = cfg[node.id];
|
||
if (!nodeCfg) return node;
|
||
const { component, ...params } = nodeCfg;
|
||
return {
|
||
...node,
|
||
componentId: typeof component === 'string' ? component : node.componentId,
|
||
data: Object.keys(params).length > 0 ? { ...(node.data ?? {}), ...params } : node.data,
|
||
};
|
||
});
|
||
|
||
graph = { id: workflow.name, name: workflow.name, nodes, edges: rawGraph.edges };
|
||
searchSpinner.succeed('執行圖解析完成');
|
||
} catch (e) {
|
||
searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||
const consent = await obtainExposureConsent({
|
||
kind: 'workflow',
|
||
resourceName: workflow.name,
|
||
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
|
||
});
|
||
if (!consent) {
|
||
process.exit(1);
|
||
}
|
||
|
||
// POST 至 /webhooks/named
|
||
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||
try {
|
||
const res = await fetch(`${executorUrl}/webhooks/named`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
name: workflow.name,
|
||
graph,
|
||
config: workflow.config ?? {},
|
||
description: workflow.description ?? '',
|
||
exposure_consent: consent ?? undefined,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
deploySpinner.fail(chalk.red(`部署失敗(${res.status}):${err.slice(0, 200)}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const data = await res.json() as { name: string; webhook_url: string; created_at: string };
|
||
deploySpinner.succeed(chalk.green(`✓ "${workflow.name}" 已部署`));
|
||
|
||
// self-hosted:namespace 明碼 → 給「namespace 進 path」的公開 URL(公開表單可直接打,免 header)。
|
||
// standard:仍走 header(平台多租戶,api_key 是密碼不可進 path)。
|
||
if (config.mode === 'self-hosted') {
|
||
const pathUrl = `${executorUrl}/webhooks/named/${config.api_key}/${workflow.name}/trigger`;
|
||
console.log(chalk.bold(`\n Webhook URL(公開可打,免 header):${chalk.cyan(pathUrl)}`));
|
||
console.log(chalk.gray(' namespace 在 path 是明碼分區標籤(非密碼);要防外部濫用請對 webhook 加保護。'));
|
||
console.log('');
|
||
console.log(chalk.gray(' 公開表單 / curl 觸發:'));
|
||
console.log(` ${chalk.cyan(`curl -X POST ${pathUrl} \\`)}`);
|
||
console.log(` ${chalk.cyan(` -H 'Content-Type: application/json' -d '{"key": "value"}'`)}`);
|
||
} else {
|
||
console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(data.webhook_url)}`));
|
||
console.log(chalk.gray(` 需帶 Header:X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...`));
|
||
console.log('');
|
||
console.log(chalk.gray(' curl 觸發範例:'));
|
||
console.log(` ${chalk.cyan(`curl -X POST ${data.webhook_url} \\`)}`);
|
||
console.log(` ${chalk.cyan(` -H 'X-Arcrun-API-Key: ${config.api_key}' \\`)}`);
|
||
console.log(` ${chalk.cyan(` -H 'Content-Type: application/json' -d '{"key": "value"}'`)}`);
|
||
}
|
||
console.log('');
|
||
console.log(chalk.gray(' 測試執行:') + ` ${chalk.cyan(`acr run ${workflow.name}`)}`);
|
||
console.log('');
|
||
} catch (e) {
|
||
deploySpinner.fail(chalk.red(`部署失敗:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|