@atomservice/functions-cli 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/build.ts +200 -35
- package/src/client.ts +4 -4
- package/src/commands/bundle.ts +25 -1
- package/src/commands/deploy.ts +277 -42
- package/src/commands/dev.ts +160 -18
- package/src/commands/function.ts +24 -9
- package/src/commands/init.ts +1 -0
- package/src/commands/invoke.ts +187 -16
- package/src/commands/list.ts +35 -5
- package/src/commands/logs.ts +88 -12
- package/src/commands/rollback.ts +155 -29
- package/src/commands/whoami.ts +11 -7
- package/src/consts.ts +3 -0
- package/src/context.ts +11 -6
- package/src/index.ts +0 -2
- package/src/scaffold.ts +58 -42
- package/src/types.ts +3 -1
- package/src/utils.ts +5 -0
- package/src/commands/new.ts +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atomservice/functions-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "函数服务命令行工具(atomfunctions):项目脚手架、本地构建、部署、运维",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "openorson",
|
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
"atomfn": "./bin/functions.ts"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@atomservice/functions-sdk": "0.1.
|
|
33
|
+
"@atomservice/functions-sdk": "0.1.8",
|
|
34
34
|
"@clack/prompts": "1.5.0",
|
|
35
|
+
"change-case": "5.4.4",
|
|
35
36
|
"citty": "0.2.2",
|
|
37
|
+
"listr2": "10.2.1",
|
|
36
38
|
"picocolors": "1.1.1",
|
|
37
39
|
"typebox": "1.2.2"
|
|
38
40
|
},
|
package/src/build.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import { readdir, rm } from "node:fs/promises"
|
|
1
|
+
import { chmod, readdir, rm } from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { $ } from "bun"
|
|
4
|
-
import { BUNDLE_FILE, ENTRY_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE,
|
|
4
|
+
import { BUNDLE_FILE, ENTRY_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE, SDK_PACKAGE } from "./consts.ts"
|
|
5
5
|
import type { BundleConfig, DeployManifest, FunctionFileConfig } from "./types.ts"
|
|
6
6
|
import { readJson } from "./utils.ts"
|
|
7
7
|
|
|
8
|
+
async function spawnQuiet(cmd: string[], timeoutMs: number): Promise<{ exitCode: number; stderr: string }> {
|
|
9
|
+
const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "pipe" })
|
|
10
|
+
const timer = setTimeout(() => proc.kill(), timeoutMs)
|
|
11
|
+
try {
|
|
12
|
+
const exitCode = await proc.exited
|
|
13
|
+
clearTimeout(timer)
|
|
14
|
+
const stderr = await new Response(proc.stderr).text()
|
|
15
|
+
return { exitCode, stderr }
|
|
16
|
+
} catch (err) {
|
|
17
|
+
clearTimeout(timer)
|
|
18
|
+
throw err
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export interface BuildResult {
|
|
9
23
|
manifest: DeployManifest
|
|
10
24
|
artifactPath: string
|
|
@@ -81,11 +95,11 @@ export async function writeEntry(bundleDir: string, functions: ResolvedFunction[
|
|
|
81
95
|
return entryPath
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
async function compileBinary(bundleDir: string, functions: ResolvedFunction[]): Promise<string> {
|
|
98
|
+
async function compileBinary(bundleDir: string, functions: ResolvedFunction[], target: string): Promise<string> {
|
|
85
99
|
const entryPath = await writeEntry(bundleDir, functions)
|
|
86
100
|
const outPath = path.join(bundleDir, ".atomfn-bundle")
|
|
87
101
|
try {
|
|
88
|
-
const result = await $`bun build --compile --minify --target=${
|
|
102
|
+
const result = await $`bun build --compile --minify --target=${target} ${entryPath} --outfile ${outPath}`
|
|
89
103
|
.cwd(bundleDir)
|
|
90
104
|
.quiet()
|
|
91
105
|
.nothrow()
|
|
@@ -98,7 +112,7 @@ async function compileBinary(bundleDir: string, functions: ResolvedFunction[]):
|
|
|
98
112
|
|
|
99
113
|
async function podmanBuildAndSave(bundleDir: string, image: string): Promise<string> {
|
|
100
114
|
const containerfile = path.join(bundleDir, "Containerfile")
|
|
101
|
-
const build = await $`podman build -t ${image} -f ${containerfile} ${bundleDir}`.quiet().nothrow()
|
|
115
|
+
const build = await $`podman build --pull=missing -t ${image} -f ${containerfile} ${bundleDir}`.quiet().nothrow()
|
|
102
116
|
if (build.exitCode !== 0) throw new Error(`镜像构建失败:${build.stderr.toString().slice(-500)}`)
|
|
103
117
|
|
|
104
118
|
const tarPath = path.join(bundleDir, ".atomfn-image.tar")
|
|
@@ -112,9 +126,10 @@ async function buildImage(
|
|
|
112
126
|
project: string,
|
|
113
127
|
bundle: string,
|
|
114
128
|
version: string,
|
|
129
|
+
arch: string,
|
|
115
130
|
binaryPath: string,
|
|
116
131
|
): Promise<string> {
|
|
117
|
-
const image = `localhost/atomfn/${project}-${bundle}:${version}`
|
|
132
|
+
const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
|
|
118
133
|
const stagedBinary = path.join(bundleDir, "bundle")
|
|
119
134
|
await Bun.write(stagedBinary, Bun.file(binaryPath))
|
|
120
135
|
try {
|
|
@@ -145,45 +160,192 @@ async function hashTree(dir: string): Promise<string> {
|
|
|
145
160
|
return hasher.digest("hex").slice(0, 16)
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
async function parseRustFunctions(bundleDir: string): Promise<string[]> {
|
|
164
|
+
const mainPath = path.join(bundleDir, "src", "main.rs")
|
|
165
|
+
if (!(await Bun.file(mainPath).exists())) return []
|
|
166
|
+
|
|
167
|
+
const source = await Bun.file(mainPath).text()
|
|
168
|
+
const macroMatch = source.match(/serve!\s*\{([^}]*)\}/)
|
|
169
|
+
if (!macroMatch) return []
|
|
170
|
+
|
|
171
|
+
const body = macroMatch[1] ?? ""
|
|
172
|
+
const functions: string[] = []
|
|
173
|
+
const entryRegex = /(stream\s+)?([a-z][a-z0-9_]*)/g
|
|
174
|
+
for (let match = entryRegex.exec(body); match !== null; match = entryRegex.exec(body)) {
|
|
175
|
+
const name = match[2]
|
|
176
|
+
if (name) functions.push(name)
|
|
177
|
+
}
|
|
178
|
+
return functions.sort()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function generateRustConfig(
|
|
182
|
+
bundleDir: string,
|
|
183
|
+
functions: string[],
|
|
184
|
+
defaults: FunctionFileConfig = {},
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const config: Record<string, FunctionFileConfig> = {}
|
|
187
|
+
for (const fn of functions) {
|
|
188
|
+
const fnConfigPath = path.join(bundleDir, "src", "functions", fn, FUNCTION_CONFIG_FILE)
|
|
189
|
+
const fnConfig = await readJson<FunctionFileConfig>(fnConfigPath)
|
|
190
|
+
config[fn] = { ...defaults, ...(fnConfig ?? {}) }
|
|
191
|
+
}
|
|
192
|
+
const configPath = path.join(bundleDir, "functions.config.json")
|
|
193
|
+
await Bun.write(configPath, JSON.stringify({ functions: config }, null, 2))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function podmanMachineArch(): Promise<string> {
|
|
197
|
+
try {
|
|
198
|
+
const proc = Bun.spawn(["podman", "info", "--format", "{{.Host.Arch}}"], { stdout: "pipe", stderr: "ignore" })
|
|
199
|
+
await proc.exited
|
|
200
|
+
const out = (await new Response(proc.stdout).text()).trim()
|
|
201
|
+
return out === "amd64" ? "x64" : out // normalize amd64→x64
|
|
202
|
+
} catch {
|
|
203
|
+
return "x64"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function buildRustBinary(bundleDir: string, bundle: string, arch: string): Promise<string> {
|
|
208
|
+
const outPath = path.join(bundleDir, ".atomfn-bundle")
|
|
209
|
+
const machineArch = await podmanMachineArch()
|
|
210
|
+
|
|
211
|
+
let cmd: string[]
|
|
212
|
+
let binaryRelPath: string
|
|
213
|
+
|
|
214
|
+
if (machineArch === arch) {
|
|
215
|
+
// podman machine 与目标架构相同,原生编译
|
|
216
|
+
cmd = [
|
|
217
|
+
"podman",
|
|
218
|
+
"run",
|
|
219
|
+
"--rm",
|
|
220
|
+
"--pull=missing",
|
|
221
|
+
"-v",
|
|
222
|
+
`${bundleDir}:/build:Z`,
|
|
223
|
+
"-e",
|
|
224
|
+
"CARGO_TARGET_DIR=/build/target",
|
|
225
|
+
"-w",
|
|
226
|
+
"/build",
|
|
227
|
+
"rust:1-slim-bookworm",
|
|
228
|
+
"cargo",
|
|
229
|
+
"build",
|
|
230
|
+
"--release",
|
|
231
|
+
]
|
|
232
|
+
binaryRelPath = path.join("target", "release", bundle)
|
|
233
|
+
} else {
|
|
234
|
+
// 跨架构编译,使用交叉编译工具链(避免 QEMU)
|
|
235
|
+
const rustTarget = arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
|
|
236
|
+
const aptPackage = arch === "arm64" ? "gcc-aarch64-linux-gnu" : "gcc-x86-64-linux-gnu"
|
|
237
|
+
const linker = arch === "arm64" ? "aarch64-linux-gnu-gcc" : "x86_64-linux-gnu-gcc"
|
|
238
|
+
const linkerEnvKey =
|
|
239
|
+
arch === "arm64"
|
|
240
|
+
? "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER"
|
|
241
|
+
: "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER"
|
|
242
|
+
cmd = [
|
|
243
|
+
"podman",
|
|
244
|
+
"run",
|
|
245
|
+
"--rm",
|
|
246
|
+
"--pull=missing",
|
|
247
|
+
"-v",
|
|
248
|
+
`${bundleDir}:/build:Z`,
|
|
249
|
+
"-e",
|
|
250
|
+
"CARGO_TARGET_DIR=/build/target",
|
|
251
|
+
"-e",
|
|
252
|
+
`${linkerEnvKey}=${linker}`,
|
|
253
|
+
"-w",
|
|
254
|
+
"/build",
|
|
255
|
+
"rust:1-slim-bookworm",
|
|
256
|
+
"sh",
|
|
257
|
+
"-c",
|
|
258
|
+
`apt-get update -qq && apt-get install -y -q ${aptPackage} && cargo build --target ${rustTarget} --release`,
|
|
259
|
+
]
|
|
260
|
+
binaryRelPath = path.join("target", rustTarget, "release", bundle)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = await spawnQuiet(cmd, 600_000)
|
|
264
|
+
|
|
265
|
+
if (result.exitCode !== 0) {
|
|
266
|
+
throw new Error(`Rust 编译失败(${arch}):${result.stderr.slice(-500)}`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const binaryPath = path.join(bundleDir, binaryRelPath)
|
|
270
|
+
await Bun.write(outPath, Bun.file(binaryPath))
|
|
271
|
+
await chmod(outPath, 0o755)
|
|
272
|
+
return outPath
|
|
273
|
+
}
|
|
274
|
+
|
|
148
275
|
async function buildRustBundle(
|
|
149
276
|
root: string,
|
|
150
277
|
project: string,
|
|
151
278
|
bundle: string,
|
|
152
279
|
config: BundleConfig,
|
|
280
|
+
arch: string,
|
|
153
281
|
): Promise<BuildResult> {
|
|
154
|
-
const bundleDir = path.join(root, bundle)
|
|
155
|
-
const functions =
|
|
282
|
+
const bundleDir = path.join(root, "bundles", bundle)
|
|
283
|
+
const functions = await parseRustFunctions(bundleDir)
|
|
156
284
|
if (functions.length === 0) {
|
|
157
|
-
throw new Error(`rust bundle ${bundle}
|
|
158
|
-
}
|
|
159
|
-
if (!(await Bun.file(path.join(bundleDir, "Containerfile")).exists())) {
|
|
160
|
-
throw new Error(`rust bundle ${bundle} 缺少 Containerfile`)
|
|
285
|
+
throw new Error(`rust bundle ${bundle} 的 src/main.rs 中没有找到 serve! 宏声明`)
|
|
161
286
|
}
|
|
287
|
+
|
|
288
|
+
await generateRustConfig(bundleDir, functions, config.defaults)
|
|
162
289
|
const version = await hashTree(bundleDir)
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
290
|
+
|
|
291
|
+
const hasContainerfile = await Bun.file(path.join(bundleDir, "Containerfile")).exists()
|
|
292
|
+
|
|
293
|
+
if (!hasContainerfile) {
|
|
294
|
+
const binaryPath = await buildRustBinary(bundleDir, bundle, arch)
|
|
295
|
+
return {
|
|
296
|
+
manifest: {
|
|
297
|
+
project,
|
|
298
|
+
bundle,
|
|
299
|
+
arch,
|
|
300
|
+
version,
|
|
301
|
+
artifact: "binary",
|
|
302
|
+
functions,
|
|
303
|
+
memory: config.memory,
|
|
304
|
+
cpus: config.cpus,
|
|
305
|
+
maxConcurrency: config.maxConcurrency,
|
|
306
|
+
env: config.env,
|
|
307
|
+
},
|
|
308
|
+
artifactPath: binaryPath,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 有 Containerfile:与 bun 流程对齐——先交叉编译 binary,再用 Containerfile 打包运行时镜像
|
|
313
|
+
const binaryPath = await buildRustBinary(bundleDir, bundle, arch)
|
|
314
|
+
try {
|
|
315
|
+
const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
|
|
316
|
+
const tarPath = await buildImage(bundleDir, project, bundle, version, arch, binaryPath)
|
|
317
|
+
return {
|
|
318
|
+
manifest: {
|
|
319
|
+
project,
|
|
320
|
+
bundle,
|
|
321
|
+
arch,
|
|
322
|
+
version,
|
|
323
|
+
artifact: "image",
|
|
324
|
+
functions,
|
|
325
|
+
image,
|
|
326
|
+
memory: config.memory,
|
|
327
|
+
cpus: config.cpus,
|
|
328
|
+
maxConcurrency: config.maxConcurrency,
|
|
329
|
+
env: config.env,
|
|
330
|
+
},
|
|
331
|
+
artifactPath: tarPath,
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
await rm(binaryPath, { force: true })
|
|
179
335
|
}
|
|
180
336
|
}
|
|
181
337
|
|
|
182
|
-
export async function buildBundle(
|
|
183
|
-
|
|
338
|
+
export async function buildBundle(
|
|
339
|
+
root: string,
|
|
340
|
+
project: string,
|
|
341
|
+
bundle: string,
|
|
342
|
+
target: string,
|
|
343
|
+
arch: string,
|
|
344
|
+
): Promise<BuildResult> {
|
|
345
|
+
const bundleDir = path.join(root, "bundles", bundle)
|
|
184
346
|
const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
|
|
185
347
|
if (!config) throw new Error(`未找到 ${bundle}/${BUNDLE_FILE}`)
|
|
186
|
-
if (config.language === "rust") return buildRustBundle(root, project, bundle, config)
|
|
348
|
+
if (config.language === "rust") return buildRustBundle(root, project, bundle, config, arch)
|
|
187
349
|
if (config.language !== "bun") throw new Error(`暂不支持的语言:${config.language}`)
|
|
188
350
|
|
|
189
351
|
const functions = await resolveFunctions(bundleDir, config.defaults)
|
|
@@ -195,18 +357,20 @@ export async function buildBundle(root: string, project: string, bundle: string)
|
|
|
195
357
|
const hasContainerfile = await Bun.file(path.join(bundleDir, "Containerfile")).exists()
|
|
196
358
|
|
|
197
359
|
if (hasContainerfile) {
|
|
198
|
-
const binaryPath = await compileBinary(bundleDir, functions)
|
|
360
|
+
const binaryPath = await compileBinary(bundleDir, functions, target)
|
|
199
361
|
try {
|
|
200
|
-
const
|
|
362
|
+
const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
|
|
363
|
+
const tarPath = await buildImage(bundleDir, project, bundle, version, arch, binaryPath)
|
|
201
364
|
return {
|
|
202
365
|
manifest: {
|
|
203
366
|
project,
|
|
204
367
|
bundle,
|
|
368
|
+
arch,
|
|
205
369
|
version,
|
|
206
370
|
artifact: "image",
|
|
207
371
|
functions: names,
|
|
208
372
|
asyncFunctions: asyncFunctions.length ? asyncFunctions : undefined,
|
|
209
|
-
image
|
|
373
|
+
image,
|
|
210
374
|
memory: config.memory,
|
|
211
375
|
cpus: config.cpus,
|
|
212
376
|
maxConcurrency: config.maxConcurrency,
|
|
@@ -219,11 +383,12 @@ export async function buildBundle(root: string, project: string, bundle: string)
|
|
|
219
383
|
}
|
|
220
384
|
}
|
|
221
385
|
|
|
222
|
-
const binaryPath = await compileBinary(bundleDir, functions)
|
|
386
|
+
const binaryPath = await compileBinary(bundleDir, functions, target)
|
|
223
387
|
return {
|
|
224
388
|
manifest: {
|
|
225
389
|
project,
|
|
226
390
|
bundle,
|
|
391
|
+
arch,
|
|
227
392
|
version,
|
|
228
393
|
artifact: "binary",
|
|
229
394
|
functions: names,
|
package/src/client.ts
CHANGED
|
@@ -55,10 +55,10 @@ export class AdminClient {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
async deploy(project: string, manifest: DeployManifest, artifact: Blob): Promise<BundleRecord> {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const data = await this.send<{ bundle: BundleRecord }>("POST", `/projects/${project}/bundles`, {
|
|
59
|
+
headers: { "x-atomfn-manifest": JSON.stringify(manifest), "content-type": "application/octet-stream" },
|
|
60
|
+
body: artifact,
|
|
61
|
+
})
|
|
62
62
|
return data.bundle
|
|
63
63
|
}
|
|
64
64
|
|
package/src/commands/bundle.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
+
import { $ } from "bun"
|
|
2
3
|
import { confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts"
|
|
3
4
|
import { defineCommand } from "citty"
|
|
4
5
|
import pc from "picocolors"
|
|
5
6
|
import { BUNDLE_FILE, BUNDLES_DIR } from "../consts.ts"
|
|
6
7
|
import { loadContext } from "../context.ts"
|
|
7
|
-
import { scaffoldBundle } from "../scaffold.ts"
|
|
8
|
+
import { scaffoldBundle, scaffoldContainerfile } from "../scaffold.ts"
|
|
8
9
|
import type { BundleConfig, Language } from "../types.ts"
|
|
9
10
|
import { isValidSlug, readJson, unwrap } from "../utils.ts"
|
|
10
11
|
|
|
@@ -13,6 +14,7 @@ const createCommand = defineCommand({
|
|
|
13
14
|
args: {
|
|
14
15
|
slug: { type: "positional", required: false, description: "bundle 名" },
|
|
15
16
|
lang: { type: "string", description: "语言:bun(默认)" },
|
|
17
|
+
"with-containerfile": { type: "boolean", description: "生成逃生舱 Containerfile 模板" },
|
|
16
18
|
},
|
|
17
19
|
async run({ args }) {
|
|
18
20
|
intro(pc.cyan("创建函数包"))
|
|
@@ -66,6 +68,28 @@ const createCommand = defineCommand({
|
|
|
66
68
|
|
|
67
69
|
await scaffoldBundle(bundleDir, { language })
|
|
68
70
|
log.success(`已创建 bundle ${slug}`)
|
|
71
|
+
|
|
72
|
+
const withContainerfile =
|
|
73
|
+
args["with-containerfile"] === true ||
|
|
74
|
+
(await confirm({ message: "是否生成 Containerfile 模板?", initialValue: false }))
|
|
75
|
+
if (!isCancel(withContainerfile) && withContainerfile) {
|
|
76
|
+
await scaffoldContainerfile(bundleDir)
|
|
77
|
+
log.success("已生成 Containerfile")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (language === "bun") {
|
|
81
|
+
const install = await confirm({ message: "是否安装依赖包?", initialValue: true })
|
|
82
|
+
if (!isCancel(install) && install) {
|
|
83
|
+
log.info("正在安装依赖...")
|
|
84
|
+
const result = await $`bun install`.cwd(bundleDir).quiet().nothrow()
|
|
85
|
+
if (result.exitCode === 0) {
|
|
86
|
+
log.success("依赖安装完成")
|
|
87
|
+
} else {
|
|
88
|
+
log.warn("依赖安装失败,请稍后手动执行 bun install")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
outro(pc.green(`完成,路径 ${path.relative(ctx.root, bundleDir)}`))
|
|
70
94
|
},
|
|
71
95
|
})
|