diff --git a/apps/control-plane-api/src/runtime/runtimeManager.ts b/apps/control-plane-api/src/runtime/runtimeManager.ts index 2539c8d..91f44e9 100644 --- a/apps/control-plane-api/src/runtime/runtimeManager.ts +++ b/apps/control-plane-api/src/runtime/runtimeManager.ts @@ -14,7 +14,7 @@ import { } from "./sandboxConfig"; import { installSessionPackages } from "./sandboxPackageInstall"; import { ensureAliyunFcRuntime, ensureAliyunFcSandboxRuntime, invokeAliyunFc, aliyunFcLoopAgentEnv } from "./aliyunFcRuntime"; -import { claimPooledAliyunFcSandboxRuntime, claimPooledDockerRuntime, claimPooledSandboxRuntime, replenishWorkspaceSandboxPool } from "./sandboxPoolManager"; +import { claimPooledAliyunFcSandboxRuntime, claimPooledDockerRuntime, claimPooledSandboxRuntime, ensureAliyunFcSandboxProviderReady, replenishWorkspaceSandboxPool } from "./sandboxPoolManager"; import { ensureVefaasRuntime, invokeVefaas, vefaasLoopAgentConfig, vefaasLoopAgentEnv } from "./vefaasAgentRuntime"; import { ensureVefaasSandboxRuntime, killVefaasSandbox } from "./vefaasSandboxRuntime"; @@ -214,9 +214,10 @@ async function ensureSingleConfiguredSandboxRuntime(session: JsonRecord & { id: } if (config.provider === "aliyun_fc") { const canClaimPool = process.env.MAPLE_SANDBOX_POOL_CLAIM !== "false"; - const runtime = await ensureAliyunFcSandboxRuntime(session, config, { acquireRuntime: canClaimPool ? () => claimPooledAliyunFcSandboxRuntime(session, config) : undefined }); - if (config.packages.length) console.warn("[runtime] Aliyun FC sandbox package installation is not implemented; packages are expected in the FC image.", { session_id: session.id }); const workspaceId = String(session.workspace_id || asRecord(session.metadata).workspace_id || ""); + const activeConfig = workspaceId ? await ensureAliyunFcSandboxProviderReady(workspaceId, config) : config; + const runtime = await ensureAliyunFcSandboxRuntime(session, activeConfig, { acquireRuntime: canClaimPool ? () => claimPooledAliyunFcSandboxRuntime(session, activeConfig) : undefined }); + if (config.packages.length) console.warn("[runtime] Aliyun FC sandbox package installation is not implemented; packages are expected in the FC image.", { session_id: session.id }); if (workspaceId && process.env.MAPLE_SANDBOX_POOL_AUTOREPLENISH !== "false") void replenishWorkspaceSandboxPool(workspaceId).catch(() => undefined); return runtime; } diff --git a/apps/control-plane-api/src/runtime/sandboxPoolManager.ts b/apps/control-plane-api/src/runtime/sandboxPoolManager.ts index 7136f49..9e78e35 100644 --- a/apps/control-plane-api/src/runtime/sandboxPoolManager.ts +++ b/apps/control-plane-api/src/runtime/sandboxPoolManager.ts @@ -1,16 +1,24 @@ +/* eslint-disable max-lines */ +import { execFile } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { sessionsDir } from "../paths"; import { countSandboxPoolStandbyCapacity, createSandboxPoolMember, + db, expireSandboxPoolMembers, + fromJson, getWorkspace, getWorkspaceSandboxPool, + hashString, listWorkspaceSandboxPools, markSandboxPoolMemberClaimed, markSandboxPoolMemberFailed, markSandboxPoolMemberReady, + now, + toJson, updateSandboxPoolMemberRuntime } from "../store"; import type { JsonRecord } from "../types"; @@ -30,6 +38,8 @@ type VefaasSandboxConfig = Extract; type AliyunFcSandboxConfig = Extract; +const execFileAsync = promisify(execFile); + export async function replenishWorkspaceSandboxPool(workspaceId: string) { const pools = listWorkspaceSandboxPools(workspaceId); if (!pools.length) return { workspace_id: workspaceId, created: 0, reason: "workspace_not_found" }; @@ -71,14 +81,48 @@ async function replenishLocalDockerSandboxPool(workspaceId: string, desiredSize: } async function replenishAliyunFcSandboxPool(workspaceId: string, desiredSize: number, ttlMs: number) { - const config = workspaceSandboxRuntimeConfig(workspaceId, "aliyun_fc"); - if (config.provider !== "aliyun_fc") return { workspace_id: workspaceId, provider: "aliyun_fc", created: 0, reason: "provider_config_missing" }; + const rawConfig = workspaceSandboxRuntimeConfig(workspaceId, "aliyun_fc"); + if (rawConfig.provider !== "aliyun_fc") return { workspace_id: workspaceId, provider: "aliyun_fc", created: 0, reason: "provider_config_missing" }; + const config = await ensureAliyunFcSandboxProviderReady(workspaceId, rawConfig); const current = countSandboxPoolStandbyCapacity(workspaceId, "aliyun_fc"); const missing = Math.max(0, desiredSize - current); const created = await runLimited(Array.from({ length: missing }), 10, () => provisionAliyunFcStandby(workspaceId, config, ttlMs)); return { workspace_id: workspaceId, provider: "aliyun_fc", desired_size: desiredSize, created: created.filter(Boolean).length }; } +export async function ensureAliyunFcSandboxProviderReady(workspaceId: string, config: AliyunFcSandboxConfig): Promise { + if (config.invoke_url) return config; + const deployScript = process.env.MAPLE_ALIYUN_FC_SANDBOX_DEPLOY_SCRIPT || process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || "infra/aliyun/deploy_aliyun_fc_runtime.mjs"; + const functionName = config.function_name || `maple-sbx-${workspaceId.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().slice(0, 24)}-${Date.now()}`; + const payload = await runAliyunFcDeployScript(deployScript, { + ...process.env, + ALIYUN_ACCESS_KEY_ID: config.access_key_id, + ALIYUN_ACCESS_KEY_SECRET: config.access_key_secret, + ALIYUN_REGION: config.region || "cn-hangzhou", + MAPLE_ALIYUN_FC_COMPONENT: "agent-sandbox", + MAPLE_ALIYUN_FC_FUNCTION_NAME: functionName, + MAPLE_ALIYUN_FC_MEMORY_MB: String(Number(process.env.MAPLE_ALIYUN_FC_SANDBOX_MEMORY_MB || 1024)), + MAPLE_RUNTIME_FUNCTION_MAX_CONCURRENCY: String(Number(process.env.MAPLE_ALIYUN_FC_SANDBOX_CONCURRENCY || 20)), + MAPLE_ALIYUN_FC_RUNTIME_ENVS: JSON.stringify({ + ...config.envs, + MAPLE_WORKSPACE_ID: workspaceId, + MAPLE_AGENT_RUNTIME_ROLE: "sandbox", + MAPLE_AGENT_LOOP_RUNTIME: "managed-agents-platform-aliyun-fc-sandbox", + MAPLE_SKIP_CLAUDE_AGENT_SDK_INSTALL: "true" + }) + }); + const invokeUrl = String(payload.invoke_url || "").replace(/\/+$/, ""); + if (!invokeUrl) throw new Error(`Aliyun FC sandbox deploy script returned incomplete payload: ${JSON.stringify(payload)}`); + const nextConfig = { + ...config, + function_name: String(payload.function_name || payload.function_id || functionName), + invoke_url: invokeUrl, + region: String(payload.region || config.region || "cn-hangzhou") + }; + updateWorkspaceAliyunFcSandboxConfig(workspaceId, nextConfig); + return nextConfig; +} + export async function claimPooledDockerRuntime( session: JsonRecord & { id: string; workspace_path: string }, config: LocalDockerSandboxConfig @@ -313,6 +357,60 @@ function vefaasRuntimeFromSandbox( }; } +async function runAliyunFcDeployScript(script: string, env: NodeJS.ProcessEnv) { + const command = script.endsWith(".py") ? "python3" : (script.endsWith(".mjs") || script.endsWith(".js")) ? process.execPath : script; + const args = command === script ? [] : [script]; + const { stdout } = await execFileAsync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: Number(process.env.MAPLE_ALIYUN_FC_RELEASE_TIMEOUT_MS || 10 * 60 * 1000), + env + }); + return JSON.parse(stdout) as JsonRecord; +} + +function updateWorkspaceAliyunFcSandboxConfig(workspaceId: string, config: AliyunFcSandboxConfig) { + const row = db.prepare("SELECT config_json FROM workspaces WHERE id = ?").get(workspaceId) as JsonRecord | undefined; + if (!row) return; + const workspaceConfig = fromJson(String(row.config_json), {}); + const sandboxConfig = asRecord(workspaceConfig.sandbox_config); + const currentAliyun = asRecord(sandboxConfig.aliyun_fc ?? sandboxConfig.aliyun); + const aliyunPatch = { + function_name: config.function_name, + invoke_url: config.invoke_url, + region: config.region, + workspace_path: config.workspace_path, + timeout_ms: config.timeout_ms + }; + const sandboxPools = Array.isArray(workspaceConfig.sandbox_pools) + ? workspaceConfig.sandbox_pools.map((pool) => { + const poolRecord = asRecord(pool); + if (String(poolRecord.provider) !== "aliyun_fc") return pool; + return { + ...poolRecord, + config: { + ...asRecord(poolRecord.config), + ...aliyunPatch + } + }; + }) + : workspaceConfig.sandbox_pools; + const nextConfig = { + ...workspaceConfig, + sandbox_config: { + ...sandboxConfig, + aliyun_fc: { + ...currentAliyun, + ...aliyunPatch + } + }, + sandbox_pools: sandboxPools + }; + const configJson = toJson(nextConfig); + db.prepare("UPDATE workspaces SET config_json = ?, config_hash = ?, updated_at = ? WHERE id = ?").run(configJson, hashString(configJson), now(), workspaceId); +} + async function prepareStandby(runtime: VefaasSandboxRuntimeInfo) { let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { diff --git a/apps/control-plane-api/src/storage/storeWorkspaceProvisioning.ts b/apps/control-plane-api/src/storage/storeWorkspaceProvisioning.ts index 91f7e86..2395397 100644 --- a/apps/control-plane-api/src/storage/storeWorkspaceProvisioning.ts +++ b/apps/control-plane-api/src/storage/storeWorkspaceProvisioning.ts @@ -140,8 +140,8 @@ async function directAliyunFcRuntimeProvisioning(workspaceId: string, index: num const functionName = `maple-ws-${workspaceId.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().slice(0, 20)}-${index + 1}-${Date.now()}`; const creds = aliyunCredentials((providerCredentials?.aliyun ?? providerCredentials?.alibaba_cloud) as JsonRecord | undefined); const region = creds.region || defaults.aliyun_fc.region || "cn-hangzhou"; - const deployScript = process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || ""; const configuredInvokeUrl = String(process.env.MAPLE_ALIYUN_FC_INVOKE_URL || defaults.aliyun_fc.invoke_url || ""); + const deployScript = process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || (configuredInvokeUrl ? "" : "infra/aliyun/deploy_aliyun_fc_runtime.mjs"); const configuredFunctionName = String(process.env.MAPLE_ALIYUN_FC_FUNCTION_NAME || defaults.aliyun_fc.function_name || functionName); const envs = publicRuntimePoolMemberEnvs(runtimePoolMemberEnvs(defaults.aliyun_fc.envs, workspaceId, index, "managed-agents-platform-aliyun-fc")); if (!deployScript) { @@ -169,6 +169,10 @@ async function directAliyunFcRuntimeProvisioning(workspaceId: string, index: num ALIYUN_REGION: region, MAPLE_ALIYUN_FC_FUNCTION_NAME: functionName, MAPLE_ALIYUN_FC_MEMORY_MB: String(poolConfig.memory_mb), + MAPLE_ALIYUN_FC_CPU_MILLI: String(poolConfig.cpu_milli), + MAPLE_RUNTIME_FUNCTION_MIN_INSTANCES: String(poolConfig.min_instances_per_function), + MAPLE_RUNTIME_FUNCTION_MAX_INSTANCES: String(poolConfig.max_instances_per_function), + MAPLE_RUNTIME_FUNCTION_MAX_CONCURRENCY: String(poolConfig.max_concurrency_per_instance), MAPLE_ALIYUN_FC_RUNTIME_ENVS: JSON.stringify({ ...runtimeEnvOverrides(process.env.MAPLE_ALIYUN_FC_RUNTIME_ENVS), ...runtimePoolMemberEnvs(defaults.aliyun_fc.envs, workspaceId, index, "managed-agents-platform-aliyun-fc"), diff --git a/bun.lock b/bun.lock index c38ba23..670e39a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "openmaple", "dependencies": { + "@alicloud/fc20230330": "4.7.6", + "@alicloud/openapi-core": "1.0.6", "@e2b/desktop": "^2.2.3", "@volcengine/tos-sdk": "^2.9.1", "ali-oss": "^6.23.0", @@ -82,6 +84,34 @@ }, }, "packages": { + "@alicloud/credentials": ["@alicloud/credentials@2.4.5", "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.5.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.8.0", "httpx": "^2.3.3", "ini": "^1.3.5", "kitx": "^2.0.0" } }, "sha512-od1ufCxOO7cP2R4EVFliOB0kGo9lUXCibyj/mzmI6yLhxeqhqsegTzVsx5p2NJJsceKJnYcmye7FWKyLJAFBkw=="], + + "@alicloud/darabonba-array": ["@alicloud/darabonba-array@0.1.2", "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ=="], + + "@alicloud/darabonba-encode-util": ["@alicloud/darabonba-encode-util@0.0.2", "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz", { "dependencies": { "moment": "^2.29.1" } }, "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A=="], + + "@alicloud/darabonba-map": ["@alicloud/darabonba-map@0.0.1", "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw=="], + + "@alicloud/darabonba-signature-util": ["@alicloud/darabonba-signature-util@0.0.4", "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz", { "dependencies": { "@alicloud/darabonba-encode-util": "^0.0.1" } }, "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg=="], + + "@alicloud/darabonba-string": ["@alicloud/darabonba-string@1.0.3", "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1" } }, "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA=="], + + "@alicloud/endpoint-util": ["@alicloud/endpoint-util@0.0.1", "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "kitx": "^2.0.0" } }, "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg=="], + + "@alicloud/fc20230330": ["@alicloud/fc20230330@4.7.6", "https://registry.npmjs.org/@alicloud/fc20230330/-/fc20230330-4.7.6.tgz", { "dependencies": { "@alicloud/openapi-core": "^1.0.0", "@darabonba/typescript": "^1.0.0" } }, "sha512-bBisFiJGKVFxQnHo154ew8JotwAXbq8+KWpoVChnSptK/7m/G8fGuXWimMpek7pg8jYWtMcVr8eTTnjUkJqxhA=="], + + "@alicloud/gateway-pop": ["@alicloud/gateway-pop@0.0.6", "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz", { "dependencies": { "@alicloud/credentials": "^2", "@alicloud/darabonba-array": "^0.1.0", "@alicloud/darabonba-encode-util": "^0.0.2", "@alicloud/darabonba-map": "^0.0.1", "@alicloud/darabonba-signature-util": "^0.0.4", "@alicloud/darabonba-string": "^1.0.2", "@alicloud/endpoint-util": "^0.0.1", "@alicloud/gateway-spi": "^0.0.8", "@alicloud/openapi-util": "^0.3.2", "@alicloud/tea-typescript": "^1.7.1", "@alicloud/tea-util": "^1.4.8" } }, "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA=="], + + "@alicloud/gateway-spi": ["@alicloud/gateway-spi@0.0.8", "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz", { "dependencies": { "@alicloud/credentials": "^2", "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g=="], + + "@alicloud/openapi-core": ["@alicloud/openapi-core@1.0.6", "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.6.tgz", { "dependencies": { "@alicloud/credentials": "^2.4.2", "@alicloud/gateway-pop": "0.0.6", "@alicloud/gateway-spi": "^0.0.8", "@darabonba/typescript": "^1.0.2" } }, "sha512-E5meOaZmdMHgvrSuSVRjMkrvttvW1aDfnzUnO+fjloYak/o/S6F8C3NXfPuhVVO/1GRfP6vf/4Vxo+tuus1UwA=="], + + "@alicloud/openapi-util": ["@alicloud/openapi-util@0.3.2", "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.2.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1", "@alicloud/tea-util": "^1.3.0", "kitx": "^2.1.0", "sm3": "^1.0.3" } }, "sha512-EC2JvxdcOgMlBAEG0+joOh2IB1um8CPz9EdYuRfTfd1uP8Yc9D8QRUWVGjP6scnj6fWSOaHFlit9H6PrJSyFow=="], + + "@alicloud/tea-typescript": ["@alicloud/tea-typescript@1.8.0", "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz", { "dependencies": { "@types/node": "^12.0.2", "httpx": "^2.2.6" } }, "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ=="], + + "@alicloud/tea-util": ["@alicloud/tea-util@1.4.11", "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.11.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "@darabonba/typescript": "^1.0.0", "kitx": "^2.0.0" } }, "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -126,6 +156,8 @@ "@connectrpc/connect-web": ["@connectrpc/connect-web@2.0.0-rc.3", "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.0.0-rc.3.tgz", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0", "@connectrpc/connect": "2.0.0-rc.3" } }, "sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw=="], + "@darabonba/typescript": ["@darabonba/typescript@1.0.4", "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.4.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "httpx": "^2.3.2", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.45", "xml2js": "^0.6.2" } }, "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w=="], + "@e2b/desktop": ["@e2b/desktop@2.3.1", "https://registry.npmjs.org/@e2b/desktop/-/desktop-2.3.1.tgz", { "dependencies": { "e2b": "^2.27.1" } }, "sha512-/WVgs4LpOk28PU9bzMkUWPdYVI0QBPVdclpXC99bsSeNTbG5QuWiS4tgznulzwW2HE0+etxzoOhkQ7fl0gagYw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -630,6 +662,8 @@ "https-proxy-agent": ["https-proxy-agent@5.0.1", "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "httpx": ["httpx@2.3.3", "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", { "dependencies": { "@types/node": "^20", "debug": "^4.1.1" } }, "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw=="], + "humanize-ms": ["humanize-ms@1.2.1", "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -640,6 +674,8 @@ "inherits": ["inherits@2.0.4", "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ip": ["ip@1.1.9", "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", {}, "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ=="], "ip-address": ["ip-address@10.2.0", "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -694,6 +730,8 @@ "keyv": ["keyv@4.5.4", "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kitx": ["kitx@2.2.0", "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz", { "dependencies": { "@types/node": "^22.5.4" } }, "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg=="], + "levn": ["levn@0.4.1", "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "locate-path": ["locate-path@6.0.0", "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -736,6 +774,10 @@ "mkdirp": ["mkdirp@0.5.6", "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "moment": ["moment@2.30.1", "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "moment-timezone": ["moment-timezone@0.5.48", "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="], + "ms": ["ms@2.1.3", "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mysql2": ["mysql2@3.22.5", "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ=="], @@ -880,6 +922,8 @@ "signal-exit": ["signal-exit@4.1.0", "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sm3": ["sm3@1.0.3", "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz", {}, "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g=="], + "smart-buffer": ["smart-buffer@4.2.0", "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.9", "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], @@ -1004,6 +1048,10 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@alicloud/darabonba-signature-util/@alicloud/darabonba-encode-util": ["@alicloud/darabonba-encode-util@0.0.1", "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1", "moment": "^2.29.1" } }, "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw=="], + + "@alicloud/tea-typescript/@types/node": ["@types/node@12.20.55", "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1028,6 +1076,10 @@ "ftp/readable-stream": ["readable-stream@1.1.14", "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], + "httpx/@types/node": ["@types/node@20.19.43", "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "kitx/@types/node": ["@types/node@22.20.0", "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], + "lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1062,6 +1114,10 @@ "ftp/readable-stream/string_decoder": ["string_decoder@0.10.31", "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + "httpx/@types/node/undici-types": ["undici-types@6.21.0", "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "kitx/@types/node/undici-types": ["undici-types@6.21.0", "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "pac-proxy-agent/raw-body/iconv-lite": ["iconv-lite@0.4.24", "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], } } diff --git a/infra/aliyun/deploy_aliyun_fc_runtime.mjs b/infra/aliyun/deploy_aliyun_fc_runtime.mjs new file mode 100644 index 0000000..4991f2a --- /dev/null +++ b/infra/aliyun/deploy_aliyun_fc_runtime.mjs @@ -0,0 +1,432 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { createHash, createHmac, randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmdirSync, statSync, unlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { basename, join, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import FCClient, { + CreateFunctionInput, + CreateFunctionRequest, + CreateTriggerInput, + CreateTriggerRequest, + CustomRuntimeConfig, + GetFunctionRequest, + InputCodeLocation, + PutConcurrencyConfigRequest, + PutConcurrencyInput, + Tag +} from "@alicloud/fc20230330"; +import { $OpenApiUtil } from "@alicloud/openapi-core"; +import { parse as parseYaml } from "yaml"; + +const execFileAsync = promisify(execFile); +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const defaultSourceDir = join(repoRoot, "infra/vefaas/runtime-app"); +const defaultAccountFile = join(homedir(), ".agents/.account.yaml"); +const defaultTimeoutMs = 10 * 60 * 1000; + +export function parseCliArgs(argv) { + const args = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith("--")) { + args._.push(item); + continue; + } + const [rawKey, rawValue] = item.slice(2).split("=", 2); + const key = rawKey.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + if (rawValue !== undefined) { + args[key] = rawValue; + continue; + } + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + args[key] = next; + index += 1; + } else { + args[key] = "true"; + } + } + return args; +} + +export function resolveDeployConfig(env = process.env, args = {}) { + const accountFile = stringValue(args.accountFile || env.MAPLE_ALIYUN_ACCOUNT_FILE || env.ALIYUN_ACCOUNT_FILE || defaultAccountFile); + const accountAliyun = aliyunAccountFromFile(accountFile); + const functionName = safeAliyunName(stringValue(args.functionName || env.MAPLE_ALIYUN_FC_FUNCTION_NAME) || generatedName("maple-fc"), 64); + const triggerName = safeAliyunName(stringValue(args.triggerName || env.MAPLE_ALIYUN_FC_TRIGGER_NAME) || `${functionName}-http`, 128); + const memoryMb = normalizeMemoryMb(numberValue(args.memoryMb ?? env.MAPLE_ALIYUN_FC_MEMORY_MB, 2048)); + const rawCpu = numberValue(args.cpu ?? env.MAPLE_ALIYUN_FC_CPU, numberValue(env.MAPLE_ALIYUN_FC_CPU_MILLI, NaN) / 1000); + const runtimeEnvs = { + SERVER_PORT: String(numberValue(env.MAPLE_ALIYUN_FC_RUNTIME_PORT, 8000)), + ...parseJsonObject(env.MAPLE_ALIYUN_FC_RUNTIME_ENVS) + }; + return { + accessKeyId: stringValue(args.accessKeyId || env.ALIYUN_ACCESS_KEY_ID || env.MAPLE_ALIYUN_ACCESS_KEY_ID || accountAliyun.accessKeyId), + accessKeySecret: stringValue(args.accessKeySecret || env.ALIYUN_ACCESS_KEY_SECRET || env.MAPLE_ALIYUN_ACCESS_KEY_SECRET || accountAliyun.accessKeySecret), + securityToken: stringValue(args.securityToken || env.ALIYUN_SECURITY_TOKEN || env.MAPLE_ALIYUN_SECURITY_TOKEN || accountAliyun.securityToken), + accountId: stringValue(args.accountId || env.ALIYUN_ACCOUNT_ID || env.MAPLE_ALIYUN_ACCOUNT_ID || accountAliyun.accountId), + region: stringValue(args.region || env.ALIYUN_REGION || env.MAPLE_ALIYUN_REGION || accountAliyun.region) || "cn-hangzhou", + endpoint: stringValue(args.endpoint || env.MAPLE_ALIYUN_FC_ENDPOINT), + functionName, + triggerName, + sourceDir: resolve(repoRoot, stringValue(args.sourceDir || env.MAPLE_ALIYUN_FC_SOURCE_DIR) || defaultSourceDir), + runtime: stringValue(args.runtime || env.MAPLE_ALIYUN_FC_RUNTIME) || "custom.debian10", + handler: stringValue(args.handler || env.MAPLE_ALIYUN_FC_HANDLER) || "index.handler", + command: splitCommand(args.command || env.MAPLE_ALIYUN_FC_COMMAND || "./run.sh"), + commandArgs: splitCommand(args.commandArgs || env.MAPLE_ALIYUN_FC_COMMAND_ARGS || ""), + port: numberValue(args.port ?? env.MAPLE_ALIYUN_FC_RUNTIME_PORT, 8000), + memoryMb, + cpu: normalizeCpu(numberValue(rawCpu, memoryMb / 2048), memoryMb), + diskMb: normalizeDiskMb(numberValue(args.diskMb ?? env.MAPLE_ALIYUN_FC_DISK_MB, 512)), + timeoutSeconds: normalizeTimeoutSeconds(numberValue(args.timeoutSeconds ?? env.MAPLE_ALIYUN_FC_TIMEOUT_SECONDS, 1800)), + instanceConcurrency: Math.max(1, Math.floor(numberValue(args.instanceConcurrency ?? env.MAPLE_RUNTIME_FUNCTION_MAX_CONCURRENCY ?? env.MAPLE_ALIYUN_FC_INSTANCE_CONCURRENCY, 20))), + reservedConcurrency: optionalInteger(args.reservedConcurrency ?? env.MAPLE_ALIYUN_FC_RESERVED_CONCURRENCY), + envs: runtimeEnvs, + tags: { + app: "openmaple", + managed_by: "openmaple", + component: stringValue(args.component || env.MAPLE_ALIYUN_FC_COMPONENT) || "agent-runtime" + }, + replaceExisting: stringValue(args.replaceExisting || env.MAPLE_ALIYUN_FC_REPLACE_EXISTING).toLowerCase() === "true", + keepFailed: stringValue(args.keepFailed || env.MAPLE_ALIYUN_FC_KEEP_FAILED).toLowerCase() === "true" + }; +} + +export class AliyunFcRuntimeDeployer { + constructor(options = {}) { + this.client = options.client; + this.zipSourceDir = options.zipSourceDir || zipSourceDir; + this.now = options.now || (() => new Date().toISOString()); + } + + getClient(config) { + if (this.client) return this.client; + return createAliyunFcClient(config); + } + + async resolvedClient(config) { + if (this.client) return this.client; + return createAliyunFcClient(await resolveFcEndpointConfig(config)); + } + + async deploy(config) { + validateDeployConfig(config); + const client = await this.resolvedClient(config); + if (config.replaceExisting) await this.cleanup(config).catch(() => undefined); + const zipBuffer = await this.zipSourceDir(config.sourceDir); + const createFunctionRequest = new CreateFunctionRequest({ + body: new CreateFunctionInput({ + code: new InputCodeLocation({ zipFile: zipBuffer.toString("base64") }), + cpu: config.cpu, + customRuntimeConfig: new CustomRuntimeConfig({ + command: config.command, + ...(config.commandArgs.length ? { args: config.commandArgs } : {}), + port: config.port + }), + description: "OpenMaple managed agent runtime and sandbox function", + diskSize: config.diskMb, + environmentVariables: config.envs, + functionName: config.functionName, + handler: config.handler, + instanceConcurrency: config.instanceConcurrency, + internetAccess: true, + memorySize: config.memoryMb, + runtime: config.runtime, + tags: objectToTags(config.tags), + timeout: config.timeoutSeconds + }) + }); + let createdFunction = false; + try { + const functionResponse = await client.createFunction(createFunctionRequest); + createdFunction = true; + if (config.reservedConcurrency !== null) { + await client.putConcurrencyConfig(config.functionName, new PutConcurrencyConfigRequest({ + body: new PutConcurrencyInput({ reservedConcurrency: config.reservedConcurrency }) + })); + } + const triggerResponse = await client.createTrigger(config.functionName, createHttpTriggerRequest(config.triggerName)); + const invokeUrl = stringValue(triggerResponse?.body?.httpTrigger?.urlInternet).replace(/\/+$/, ""); + if (!invokeUrl) throw new Error("Aliyun FC createTrigger returned no httpTrigger.urlInternet."); + return { + provider: "aliyun_fc", + region: config.region, + function_name: config.functionName, + function_id: stringValue(functionResponse?.body?.functionId || config.functionName), + service_name: "", + trigger_name: config.triggerName, + invoke_url: invokeUrl, + source_type: "source_zip", + runtime: config.runtime, + memory_mb: config.memoryMb, + cpu: config.cpu + }; + } catch (error) { + if (createdFunction && !config.keepFailed) await this.cleanup(config).catch(() => undefined); + throw error; + } + } + + async cleanup(config) { + if (!config.functionName) throw new Error("cleanup requires MAPLE_ALIYUN_FC_FUNCTION_NAME or --function-name."); + const client = await this.resolvedClient(config); + const result = { provider: "aliyun_fc", region: config.region, function_name: config.functionName, trigger_name: config.triggerName, deleted: [], skipped: [] }; + try { + await client.deleteTrigger(config.functionName, config.triggerName); + result.deleted.push("trigger"); + } catch (error) { + if (isNotFoundError(error)) result.skipped.push("trigger_not_found"); + else throw error; + } + try { + await client.deleteFunction(config.functionName); + result.deleted.push("function"); + } catch (error) { + if (isNotFoundError(error)) result.skipped.push("function_not_found"); + else throw error; + } + return result; + } + + async status(config) { + if (!config.functionName) throw new Error("status requires MAPLE_ALIYUN_FC_FUNCTION_NAME or --function-name."); + const client = await this.resolvedClient(config); + const response = await client.getFunction(config.functionName, new GetFunctionRequest({})); + return { + provider: "aliyun_fc", + region: config.region, + function_name: config.functionName, + state: stringValue(response?.body?.state), + last_update_status: stringValue(response?.body?.lastUpdateStatus), + runtime: stringValue(response?.body?.runtime) + }; + } +} + +export function createAliyunFcClient(config) { + const clientConfig = new $OpenApiUtil.Config({ + accessKeyId: config.accessKeyId, + accessKeySecret: config.accessKeySecret, + securityToken: config.securityToken || undefined, + regionId: config.region + }); + if (config.endpoint) clientConfig.endpoint = config.endpoint; + return new FCClient(clientConfig); +} + +export async function resolveFcEndpointConfig(config) { + if (config.endpoint) return config; + const accountId = config.accountId || await resolveAliyunAccountId(config); + return { + ...config, + accountId, + endpoint: `${accountId}.${config.region}.fc.aliyuncs.com` + }; +} + +export function createHttpTriggerRequest(triggerName) { + return new CreateTriggerRequest({ + body: new CreateTriggerInput({ + description: "OpenMaple HTTP invoke trigger", + triggerName, + triggerType: "http", + triggerConfig: JSON.stringify({ + authType: "anonymous", + methods: ["GET", "POST", "OPTIONS"], + disableURLInternet: false + }) + }) + }); +} + +export async function zipSourceDir(sourceDir) { + if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) throw new Error(`source directory not found: ${sourceDir}`); + const tempDir = mkdtempSync(join(tmpdir(), "maple-aliyun-fc-")); + const zipPath = join(tempDir, "runtime.zip"); + const script = ` +import os, stat, sys, zipfile +src = os.path.abspath(sys.argv[1]) +out = os.path.abspath(sys.argv[2]) +ignore = {".git", "__pycache__", ".pytest_cache", ".venv", "node_modules"} +with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(src): + dirs[:] = [d for d in dirs if d not in ignore] + for name in files: + path = os.path.join(root, name) + rel = os.path.relpath(path, src) + info = zipfile.ZipInfo(rel) + st = os.stat(path) + info.external_attr = (st.st_mode & 0xFFFF) << 16 + with open(path, "rb") as fh: + zf.writestr(info, fh.read(), zipfile.ZIP_DEFLATED) +`; + try { + await execFileAsync("python3", ["-c", script, sourceDir, zipPath], { encoding: "utf8", timeout: defaultTimeoutMs }); + return readFileSync(zipPath); + } finally { + if (existsSync(zipPath)) unlinkSync(zipPath); + if (existsSync(tempDir)) rmdirSync(tempDir); + } +} + +function validateDeployConfig(config) { + if (!config.accessKeyId || !config.accessKeySecret) throw new Error("Aliyun FC deploy requires ALIYUN_ACCESS_KEY_ID/ALIYUN_ACCESS_KEY_SECRET or cloudProviders.aliyun in ~/.agents/.account.yaml."); + if (!config.region) throw new Error("Aliyun FC deploy requires ALIYUN_REGION."); + if (!/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(config.functionName)) throw new Error(`invalid Aliyun FC function name: ${config.functionName}`); + if (!/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/.test(config.triggerName)) throw new Error(`invalid Aliyun FC trigger name: ${config.triggerName}`); +} + +function aliyunAccountFromFile(filePath) { + if (!filePath || !existsSync(filePath)) return {}; + const parsed = parseYaml(readFileSync(filePath, "utf8")) || {}; + const providerRoot = recordValue(parsed.cloudProviders ?? parsed.cloud_providers ?? parsed.providers); + const aliyun = recordValue(providerRoot.aliyun ?? providerRoot.alibaba_cloud ?? parsed.aliyun ?? parsed.alibaba_cloud); + return { + accessKeyId: stringValue(aliyun.ALIYUN_ACCESS_KEY_ID ?? aliyun.access_key_id ?? aliyun.accessKeyId ?? aliyun.ak ?? aliyun.access_key), + accessKeySecret: stringValue(aliyun.ALIYUN_ACCESS_KEY_SECRET ?? aliyun.access_key_secret ?? aliyun.accessKeySecret ?? aliyun.sk ?? aliyun.secret_key), + securityToken: stringValue(aliyun.ALIYUN_SECURITY_TOKEN ?? aliyun.security_token ?? aliyun.securityToken), + accountId: stringValue(aliyun.ALIYUN_ACCOUNT_ID ?? aliyun.account_id ?? aliyun.accountId), + region: stringValue(aliyun.ALIYUN_REGION ?? aliyun.region) + }; +} + +async function resolveAliyunAccountId(config) { + const params = { + AccessKeyId: config.accessKeyId, + Action: "GetCallerIdentity", + Format: "JSON", + SignatureMethod: "HMAC-SHA1", + SignatureNonce: randomUUID(), + SignatureVersion: "1.0", + Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + Version: "2015-04-01" + }; + if (config.securityToken) params.SecurityToken = config.securityToken; + const canonical = canonicalQuery(params); + const signature = createHmac("sha1", `${config.accessKeySecret}&`).update(`POST&%2F&${encodeRfc3986(canonical)}`).digest("base64"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const response = await fetch("https://sts.aliyuncs.com/", { + method: "POST", + body: `${canonical}&Signature=${encodeRfc3986(signature)}`, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + signal: controller.signal + }); + const body = await response.json().catch(() => ({})); + const accountId = stringValue(body.AccountId || body.AccountID || body.accountId); + if (!response.ok || !accountId) throw new Error(`Aliyun STS GetCallerIdentity failed: ${stringValue(body.Code || body.code || response.status)}`); + return accountId; + } finally { + clearTimeout(timeout); + } +} + +function canonicalQuery(query) { + return Object.keys(query).sort().map((key) => `${encodeRfc3986(key)}=${encodeRfc3986(query[key])}`).join("&"); +} + +function encodeRfc3986(value) { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); +} + +function safeAliyunName(value, maxLength) { + const base = String(value || "maple-fc") + .replace(/[^A-Za-z0-9_-]/g, "-") + .replace(/^[0-9-]+/, "m-") + .slice(0, maxLength) + .replace(/[-_]+$/g, ""); + return /^[A-Za-z_]/.test(base) ? base || "maple-fc" : `m-${base}`.slice(0, maxLength); +} + +function generatedName(prefix) { + const seed = `${Date.now()}-${process.pid}-${Math.random()}`; + return `${prefix}-${createHash("sha1").update(seed).digest("hex").slice(0, 10)}`; +} + +function normalizeMemoryMb(value) { + const rounded = Math.ceil(Math.max(128, Number(value) || 2048) / 64) * 64; + return Math.min(32768, rounded); +} + +function normalizeCpu(value, memoryMb) { + const memoryGb = memoryMb / 1024; + const min = memoryGb / 4; + const max = memoryGb; + const clamped = Math.min(max, Math.max(min, Number.isFinite(value) ? value : memoryGb / 2)); + return Math.round(clamped * 20) / 20; +} + +function normalizeDiskMb(value) { + return Number(value) >= 10240 ? 10240 : 512; +} + +function normalizeTimeoutSeconds(value) { + return Math.min(86400, Math.max(1, Math.floor(Number(value) || 1800))); +} + +function optionalInteger(value) { + if (value === undefined || value === null || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : null; +} + +function parseJsonObject(value) { + if (!value) return {}; + const parsed = JSON.parse(String(value)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("MAPLE_ALIYUN_FC_RUNTIME_ENVS must be a JSON object."); + return Object.fromEntries(Object.entries(parsed).map(([key, val]) => [key, String(val)])); +} + +function splitCommand(value) { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + return String(value || "").split(/\s+/).map((item) => item.trim()).filter(Boolean); +} + +function objectToTags(tags) { + return Object.entries(tags || {}).filter(([, value]) => value !== undefined && value !== "").map(([key, value]) => new Tag({ key, value: String(value) })); +} + +function isNotFoundError(error) { + const message = String(error?.message || error?.data?.message || error?.data?.Message || ""); + const code = String(error?.code || error?.data?.code || error?.data?.Code || ""); + return /not.?found|404|FunctionNotFound|TriggerNotFound/i.test(`${code} ${message}`); +} + +function numberValue(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function stringValue(value) { + return typeof value === "string" || typeof value === "number" ? String(value).trim() : ""; +} + +function recordValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +async function main() { + const args = parseCliArgs(process.argv.slice(2)); + const command = stringValue(args._[0] || "deploy"); + const config = resolveDeployConfig(process.env, args); + const deployer = new AliyunFcRuntimeDeployer(); + const result = command === "cleanup" + ? await deployer.cleanup(config) + : command === "status" + ? await deployer.status(config) + : await deployer.deploy(config); + const printable = { ...result }; + if (printable.source_dir) printable.source_dir = relative(repoRoot, printable.source_dir); + process.stdout.write(`${JSON.stringify(printable)}\n`); +} + +if (basename(process.argv[1] || "") === basename(fileURLToPath(import.meta.url))) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); +} diff --git a/infra/vefaas/deploy_vefaas_stable.py b/infra/vefaas/deploy_vefaas_stable.py index 031c4f5..af8f924 100644 --- a/infra/vefaas/deploy_vefaas_stable.py +++ b/infra/vefaas/deploy_vefaas_stable.py @@ -98,14 +98,10 @@ def bootstrap(state_path: Path, clients: dict[str, Any], min_instance: int, max_ frontend_function_id = existing_function_id(clients, frontend.name) backend_function_id = existing_function_id(clients, backend.name) - if frontend_function_id: - update_existing_function(clients, frontend_function_id, config, frontend) - else: + if not frontend_function_id: frontend_function_id = app_deploy.create_function_with_code(clients["vefaas_api"], clients["app"], config, frontend) app_deploy.release_function(clients["release"], frontend_function_id, config) - if backend_function_id: - update_existing_function(clients, backend_function_id, config, backend) - else: + if not backend_function_id: backend_function_id = app_deploy.create_function_with_code(clients["vefaas_api"], clients["app"], config, backend) app_deploy.release_function(clients["release"], backend_function_id, config) ensure_resource(clients, frontend_function_id, min_instance, max_instance) @@ -209,10 +205,7 @@ def deploy_stable_role( ) -> None: function_id = str(state[role]["function_id"]) current_runtime = function_runtime(clients, function_id) - if current_runtime and current_runtime != package.runtime: - rotate_function_runtime(clients, state, role, config, package, route_paths, current_runtime, min_instance, max_instance) - return - update_existing_function(clients, function_id, config, package) + rotate_function_runtime(clients, state, role, config, package, route_paths, current_runtime or "unknown", min_instance, max_instance) def rotate_function_runtime( @@ -228,8 +221,10 @@ def rotate_function_runtime( ) -> None: previous_id = str(state[role]["function_id"]) replacement = replace(package, name=f"{package.name}-{timestamp()}") - replacement_id = app_deploy.create_function_with_code(clients["vefaas_api"], clients["app"], config, replacement) - app_deploy.release_function(clients["release"], replacement_id, config) + replacement = with_existing_envs(clients["vefaas_api"], previous_id, replacement) + create_config = config_with_existing_vpc(clients["vefaas_api"], previous_id, config) + replacement_id = app_deploy.create_function_with_code(clients["vefaas_api"], clients["app"], create_config, replacement) + app_deploy.release_function(clients["release"], replacement_id, create_config) try: ensure_resource(clients, replacement_id, min_instance, max_instance) wait_function_ready(clients, replacement_id, min_instance) @@ -302,6 +297,36 @@ def function_envs(api: VolcengineVefaasApi, function_id: str) -> dict[str, str]: return result +def config_with_existing_vpc(api: VolcengineVefaasApi, function_id: str, config: DeployConfig) -> DeployConfig: + vpc = function_vpc_config(api, function_id) + if not vpc: + return config + return replace( + config, + vpc_id=vpc["vpc_id"], + subnet_ids=vpc["subnet_ids"], + security_group_ids=vpc["security_group_ids"], + enable_shared_internet_access=vpc["enable_shared_internet_access"], + ) + + +def function_vpc_config(api: VolcengineVefaasApi, function_id: str) -> dict[str, Any]: + if getattr(api, "openapi", None): + data = api.openapi.post("GetFunction", {"Id": function_id}).get("Result", {}) + else: + sdk = api.sdk + data = to_plain_dict(api.client.get_function(sdk.GetFunctionRequest(id=function_id))) + raw = data.get("VpcConfig") or data.get("vpc_config") or {} + if not (raw.get("EnableVpc") or raw.get("enable_vpc")): + return {} + return { + "vpc_id": str(raw.get("VpcId") or raw.get("vpc_id") or ""), + "subnet_ids": [str(item) for item in (raw.get("SubnetIds") or raw.get("subnet_ids") or [])], + "security_group_ids": [str(item) for item in (raw.get("SecurityGroupIds") or raw.get("security_group_ids") or [])], + "enable_shared_internet_access": bool(raw.get("EnableSharedInternetAccess") or raw.get("enable_shared_internet_access")), + } + + def function_runtime(clients: dict[str, Any], function_id: str) -> str: api = clients["vefaas_api"] if getattr(api, "openapi", None): @@ -516,7 +541,7 @@ def service_url(service: dict[str, Any]) -> str: def ensure_upstream(clients: dict[str, Any], gateway_id: str, name: str, function_id: str) -> str: - existing = find_upstream(clients, gateway_id, name) + existing = find_upstream(clients, gateway_id, name, function_id=function_id) if existing: return str(existing["id"]) sdk = clients["apig_api"].sdk @@ -537,18 +562,24 @@ def ensure_upstream(clients: dict[str, Any], gateway_id: str, name: str, functio return str(upstream_id) -def find_upstream(clients: dict[str, Any], gateway_id: str, name: str) -> dict[str, Any] | None: +def find_upstream(clients: dict[str, Any], gateway_id: str, name: str, *, function_id: str | None = None) -> dict[str, Any] | None: for page in range(1, 20): data = clients["apig_2021"].post("ListUpstreams", {"GatewayId": gateway_id, "PageNumber": page, "PageSize": 50}).get("Result", {}) items = data.get("Items") or data.get("Upstreams") or [] for item in items: - if item.get("Name") == name: + if item.get("Name") == name or (function_id and upstream_function_id(item) == function_id): return {"id": item.get("Id"), "name": item.get("Name")} if len(items) < 50: break return None +def upstream_function_id(upstream: dict[str, Any]) -> str: + spec = upstream.get("UpstreamSpec") or upstream.get("upstream_spec") or {} + vefaas = spec.get("VeFaas") or spec.get("ve_faas") or {} + return str(vefaas.get("FunctionId") or vefaas.get("function_id") or "") + + def ensure_route(clients: dict[str, Any], service_id: str, suffix: str, path: str, upstream_id: str, *, priority: int) -> str: route = find_route(clients, service_id, path) if route: @@ -759,8 +790,11 @@ def assert_probes_ok(probes: dict[str, Any]) -> None: failures: list[str] = [] for name, probe in probes.items(): status = str(probe.get("status") or "") + body = str(probe.get("body_prefix") or "") + if name == "auth_start" and status == "501" and "auth_provider_not_configured" in body: + continue if not probe_ok(probe): - detail = probe.get("error") or f"status={status} body={str(probe.get('body_prefix') or '')[:160]}" + detail = probe.get("error") or f"status={status} body={body[:160]}" failures.append(f"{name}: {detail}") continue if name == "auth_start": diff --git a/package.json b/package.json index 5af2bd4..2cf4f01 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test:vefaas-runner-pool": "python3 tests/contracts/vefaas_runner_pool_contract.py", "test:vefaas-sandbox": "bun tests/contracts/vefaas_sandbox_contract.ts", "test:onboarding-sandbox-pool": "bun tests/contracts/workspace_onboarding_sandbox_pool_contract.ts", + "test:aliyun-fc-deploy": "bun tests/contracts/aliyun_fc_deploy_contract.ts", "test:aliyun-provider-orchestration": "bun tests/contracts/aliyun_provider_orchestration_contract.ts", "test:local-docker-provider": "bun tests/contracts/local_docker_provider_contract.ts", "test:local-docker-compose": "bun tests/contracts/local_docker_compose_contract.ts", @@ -66,14 +67,16 @@ "test:volcengine-credentials": "bun tests/contracts/volcengine_credential_validation_contract.ts", "test:ci-wrapper": "bun tests/contracts/ci_wrapper_contract.ts", "test:cloud-min-story": "bun tests/e2e/cloud_min_user_story.mjs", - "ci:contracts": "bun scripts/ci/run-contract-tests.mjs test:bun-runtime test:project-env test:real-agent-loop-driver test:vefaas-provisioner test:vefaas-contract test:vefaas-runner-pool test:vefaas-sandbox test:onboarding-sandbox-pool test:aliyun-provider-orchestration test:local-docker-provider test:local-docker-compose test:workspace-runtime-pool test:deployment test:mysql-adapter test:agent-builder test:platform-sdk-cli test:api-storage test:prototype-console test:auth-tenant-flow test:maple-ui-interaction test:session-tool-calls test:environment-lifecycle test:environment-packages test:vault-mcp test:codebase-hygiene test:public-hygiene test:session-file-upload test:maple-docs test:npm-sdk test:maple-branding test:ui-overlay test:admin-web-i18n test:tenant-cloud-provider test:volcengine-credentials test:ci-wrapper", + "ci:contracts": "bun scripts/ci/run-contract-tests.mjs test:bun-runtime test:project-env test:real-agent-loop-driver test:vefaas-provisioner test:vefaas-contract test:vefaas-runner-pool test:vefaas-sandbox test:onboarding-sandbox-pool test:aliyun-fc-deploy test:aliyun-provider-orchestration test:local-docker-provider test:local-docker-compose test:workspace-runtime-pool test:deployment test:mysql-adapter test:agent-builder test:platform-sdk-cli test:api-storage test:prototype-console test:auth-tenant-flow test:maple-ui-interaction test:session-tool-calls test:environment-lifecycle test:environment-packages test:vault-mcp test:codebase-hygiene test:public-hygiene test:session-file-upload test:maple-docs test:npm-sdk test:maple-branding test:ui-overlay test:admin-web-i18n test:tenant-cloud-provider test:volcengine-credentials test:ci-wrapper", "deploy:vefaas:stable": "python3 infra/vefaas/deploy_vefaas_stable.py deploy", "status:vefaas:stable": "python3 infra/vefaas/deploy_vefaas_stable.py status", "test:e2e": "E2E_ISOLATED=1 E2E_SANDBOX_PROVIDER=e2b MAPLE_SANDBOX_PROVIDER=e2b MAPLE_RUNTIME_TOOL_BRIDGE_HOST_FALLBACK=true MAPLE_AGENT_RUNTIME_PROVIDER=local MAPLE_AGENT_LOOP_EXECUTION=provider bun tests/e2e/e2e.mjs", "test:e2e:local-docker": "E2E_ISOLATED=1 E2E_SANDBOX_PROVIDER=local_docker MAPLE_LOCAL_DOCKER_MODE=true MAPLE_SANDBOX_PROVIDER=local_docker MAPLE_AGENT_RUNTIME_PROVIDER=local_docker MAPLE_AGENT_LOOP_EXECUTION=provider MAPLE_ASK_PROVIDER_TIMEOUT_MS=180000 E2E_ASK_TIMEOUT_MS=180000 bun tests/e2e/e2e.mjs", - "test:all": "bun run test:bun-runtime && bun run test:project-env && bun run test:real-agent-loop-driver && bun run test:vefaas-provisioner && bun run test:vefaas-contract && bun run test:vefaas-runner-pool && bun run test:vefaas-sandbox && bun run test:onboarding-sandbox-pool && bun run test:aliyun-provider-orchestration && bun run test:local-docker-provider && bun run test:local-docker-compose && bun run test:workspace-runtime-pool && bun run test:deployment && bun run test:mysql-adapter && bun run test:agent-builder && bun run test:platform-sdk-cli && bun run test:api-storage && bun run test:prototype-console && bun run test:auth-tenant-flow && bun run test:maple-ui-interaction && bun run test:session-tool-calls && bun run test:environment-lifecycle && bun run test:environment-packages && bun run test:vault-mcp && bun run test:codebase-hygiene && bun run test:session-file-upload && bun run test:ui-overlay && bun run test:admin-web-i18n && bun run test:tenant-cloud-provider && bun run test:volcengine-credentials && bun run typecheck && bun run build && bun run test:e2e" + "test:all": "bun run test:bun-runtime && bun run test:project-env && bun run test:real-agent-loop-driver && bun run test:vefaas-provisioner && bun run test:vefaas-contract && bun run test:vefaas-runner-pool && bun run test:vefaas-sandbox && bun run test:onboarding-sandbox-pool && bun run test:aliyun-fc-deploy && bun run test:aliyun-provider-orchestration && bun run test:local-docker-provider && bun run test:local-docker-compose && bun run test:workspace-runtime-pool && bun run test:deployment && bun run test:mysql-adapter && bun run test:agent-builder && bun run test:platform-sdk-cli && bun run test:api-storage && bun run test:prototype-console && bun run test:auth-tenant-flow && bun run test:maple-ui-interaction && bun run test:session-tool-calls && bun run test:environment-lifecycle && bun run test:environment-packages && bun run test:vault-mcp && bun run test:codebase-hygiene && bun run test:session-file-upload && bun run test:ui-overlay && bun run test:admin-web-i18n && bun run test:tenant-cloud-provider && bun run test:volcengine-credentials && bun run typecheck && bun run build && bun run test:e2e" }, "dependencies": { + "@alicloud/fc20230330": "4.7.6", + "@alicloud/openapi-core": "1.0.6", "@e2b/desktop": "^2.2.3", "@volcengine/tos-sdk": "^2.9.1", "ali-oss": "^6.23.0", diff --git a/tests/contracts/aliyun_fc_deploy_contract.ts b/tests/contracts/aliyun_fc_deploy_contract.ts new file mode 100644 index 0000000..78ee325 --- /dev/null +++ b/tests/contracts/aliyun_fc_deploy_contract.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AliyunFcRuntimeDeployer, + createHttpTriggerRequest, + resolveFcEndpointConfig, + resolveDeployConfig +} from "../../infra/aliyun/deploy_aliyun_fc_runtime.mjs"; + +const tempDir = mkdtempSync(join(tmpdir(), "maple-aliyun-fc-contract-")); +const sourceDir = join(tempDir, "runtime-app"); +const accountFile = join(tempDir, "account.yaml"); +mkdirSync(sourceDir); +writeFileSync(join(sourceDir, ".keep"), ""); +writeFileSync(join(sourceDir, "run.sh"), "#!/bin/bash\nexec python3 app.py\n"); +chmodSync(join(sourceDir, "run.sh"), 0o755); +writeFileSync(accountFile, ` +cloudProviders: + aliyun: + accessKeyId: contract-ak + accessKeySecret: contract-sk + region: cn-hangzhou +`); + +const config = resolveDeployConfig({ + MAPLE_ALIYUN_ACCOUNT_FILE: accountFile, + MAPLE_ALIYUN_FC_FUNCTION_NAME: "1 invalid name", + MAPLE_ALIYUN_FC_TRIGGER_NAME: "contract-trigger", + MAPLE_ALIYUN_FC_MEMORY_MB: "512", + MAPLE_ALIYUN_FC_RUNTIME_ENVS: JSON.stringify({ MAPLE_SKIP_CLAUDE_AGENT_SDK_INSTALL: "true" }) +}, { sourceDir }); + +assert.equal(config.accessKeyId, "contract-ak"); +assert.equal(config.accessKeySecret, "contract-sk"); +assert.equal(config.region, "cn-hangzhou"); +assert.equal(config.runtime, "custom.debian10"); +assert.equal(config.memoryMb, 512); +assert.equal(config.cpu, 0.25); +assert.match(config.functionName, /^m-/); +assert.equal(config.envs.MAPLE_SKIP_CLAUDE_AGENT_SDK_INSTALL, "true"); + +const endpointConfig = await resolveFcEndpointConfig({ ...config, accountId: "1234567890123456", endpoint: "" }); +assert.equal(endpointConfig.endpoint, "1234567890123456.cn-hangzhou.fc.aliyuncs.com"); + +const calls: Array> = []; +const fakeClient = { + async createFunction(request: Record) { + calls.push({ type: "createFunction", request }); + return { body: { functionId: "fc-contract-function-id" } }; + }, + async createTrigger(functionName: string, request: Record) { + calls.push({ type: "createTrigger", functionName, request }); + return { body: { httpTrigger: { urlInternet: "https://contract-fc.cn-hangzhou.fcapp.run/" } } }; + }, + async putConcurrencyConfig(functionName: string, request: Record) { + calls.push({ type: "putConcurrencyConfig", functionName, request }); + return {}; + }, + async deleteTrigger(functionName: string, triggerName: string) { + calls.push({ type: "deleteTrigger", functionName, triggerName }); + return {}; + }, + async deleteFunction(functionName: string) { + calls.push({ type: "deleteFunction", functionName }); + return {}; + } +}; + +const deployer = new AliyunFcRuntimeDeployer({ + client: fakeClient, + zipSourceDir: async () => Buffer.from("zip-bytes") +}); +const deployResult = await deployer.deploy({ + ...config, + functionName: "maple_contract_fc", + triggerName: "maple_contract_fc_http", + reservedConcurrency: 2 +}); + +assert.equal(deployResult.provider, "aliyun_fc"); +assert.equal(deployResult.function_name, "maple_contract_fc"); +assert.equal(deployResult.function_id, "fc-contract-function-id"); +assert.equal(deployResult.invoke_url, "https://contract-fc.cn-hangzhou.fcapp.run"); + +const createFunctionCall = calls.find((call) => call.type === "createFunction") as Record; +assert.ok(createFunctionCall); +const createFunctionBody = ((createFunctionCall.request as Record).body ?? {}) as Record; +assert.equal(createFunctionBody.functionName, "maple_contract_fc"); +assert.equal(createFunctionBody.runtime, "custom.debian10"); +assert.equal(createFunctionBody.memorySize, 512); +assert.equal(createFunctionBody.cpu, 0.25); +assert.equal(createFunctionBody.instanceConcurrency, 20); +assert.equal(((createFunctionBody.code as Record).zipFile), Buffer.from("zip-bytes").toString("base64")); +assert.deepEqual((createFunctionBody.customRuntimeConfig as Record).command, ["./run.sh"]); +assert.equal((createFunctionBody.customRuntimeConfig as Record).port, 8000); +assert.equal((createFunctionBody.environmentVariables as Record).MAPLE_SKIP_CLAUDE_AGENT_SDK_INSTALL, "true"); + +const createTriggerCall = calls.find((call) => call.type === "createTrigger") as Record; +assert.ok(createTriggerCall); +const createTriggerBody = ((createTriggerCall.request as Record).body ?? {}) as Record; +assert.equal(createTriggerBody.triggerName, "maple_contract_fc_http"); +assert.equal(createTriggerBody.triggerType, "http"); +const triggerConfig = JSON.parse(String(createTriggerBody.triggerConfig)); +assert.equal(triggerConfig.authType, "anonymous"); +assert.equal(triggerConfig.disableURLInternet, false); +assert.deepEqual(triggerConfig.methods, ["GET", "POST", "OPTIONS"]); + +const request = createHttpTriggerRequest("maple_contract_fc_http"); +assert.equal((request.body as Record).triggerType, "http"); + +await deployer.cleanup({ ...config, functionName: "maple_contract_fc", triggerName: "maple_contract_fc_http" }); +assert.deepEqual(calls.slice(-2).map((call) => call.type), ["deleteTrigger", "deleteFunction"]); + +const provisioningSource = readFileSync("apps/control-plane-api/src/storage/storeWorkspaceProvisioning.ts", "utf8"); +assert.match(provisioningSource, /infra\/aliyun\/deploy_aliyun_fc_runtime\.mjs/); +assert.match(provisioningSource, /MAPLE_ALIYUN_FC_CPU_MILLI/); +const sandboxPoolSource = readFileSync("apps/control-plane-api/src/runtime/sandboxPoolManager.ts", "utf8"); +assert.match(sandboxPoolSource, /MAPLE_ALIYUN_FC_SANDBOX_DEPLOY_SCRIPT/); +assert.match(sandboxPoolSource, /ensureAliyunFcSandboxProviderReady/); + +console.log("aliyun fc deploy contract passed"); diff --git a/tests/contracts/vefaas_provisioner_contract.py b/tests/contracts/vefaas_provisioner_contract.py index 3cf8068..5ccbaf1 100644 --- a/tests/contracts/vefaas_provisioner_contract.py +++ b/tests/contracts/vefaas_provisioner_contract.py @@ -123,8 +123,8 @@ def test_workspace_pool_passes_resource_limits_to_runtime_deploy_script(): source = STORE_PROVISIONING_PATH.read_text() assert "MAPLE_RUNTIME_FUNCTION_MIN_INSTANCES: String(poolConfig.min_instances_per_function)," in source assert "MAPLE_RUNTIME_FUNCTION_MAX_INSTANCES: String(poolConfig.max_instances_per_function)," in source - assert source.count("MAPLE_RUNTIME_FUNCTION_MIN_INSTANCES") == 3 - assert source.count("MAPLE_RUNTIME_FUNCTION_MAX_INSTANCES") == 3 + assert source.count("MAPLE_RUNTIME_FUNCTION_MIN_INSTANCES") >= 4 + assert source.count("MAPLE_RUNTIME_FUNCTION_MAX_INSTANCES") >= 4 assert 'const configuredImage = String(process.env.MAPLE_VEFAAS_IMAGE || "").trim();' in source assert 'payload = await runDeploy(`${appName}-src`, { MAPLE_VEFAAS_IMAGE: "" });' in source assert 'payload = await runDeploy(appName, { MAPLE_VEFAAS_IMAGE: "" });' in source