@newbeebox/newbeebox-app-engine-cli 1.7.1 → 1.8.1
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/README.md +22 -0
- package/package.json +1 -1
- package/src/build.js +160 -0
- package/src/forward.js +5 -1
- package/src/http.js +6 -1
- package/src/index.js +47 -0
- package/src/tar.js +198 -0
package/README.md
CHANGED
|
@@ -90,6 +90,28 @@ nae create --kind template --app-id myredis \
|
|
|
90
90
|
--template redis --storage 2Gi --config '{"password":"..."}'
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### 构建(本机无 Docker)
|
|
94
|
+
|
|
95
|
+
没装 Docker、或不想本地 `docker build && push`?把带 `Dockerfile` 的项目目录交给平台代构建,
|
|
96
|
+
构建成功推镜像后照常触发自动部署(与你本地 `push` 等效)。
|
|
97
|
+
|
|
98
|
+
| 命令 | 说明 |
|
|
99
|
+
|------|------|
|
|
100
|
+
| `nae build <appid> [path]` | 打包本地目录上传,由平台代跑 `docker build`/`push`;缺省跟随日志直至结束 |
|
|
101
|
+
| `nae builds <appid>` | 列出该应用最近的构建任务(状态 / 排队位置) |
|
|
102
|
+
| `nae build-logs <jobid> [-f]` | 某构建任务日志(游标续读,`-f` 跟随至结束) |
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
nae build myweb # 打包当前目录,构建并跟随日志
|
|
106
|
+
nae build myweb ./service --tag v2 # 指定上下文目录与镜像 tag
|
|
107
|
+
nae build myweb --dockerfile docker/Dockerfile.prod
|
|
108
|
+
nae build myweb --no-follow # 只入队,稍后 nae build-logs <job> -f 再看
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- 打包遵循上下文根的 `.dockerignore`,务必排除 `node_modules/`、构建产物、`.git/`;压缩后上限 **200MB**。
|
|
112
|
+
- 镜像运行时约束同样适用(非 root、端口 ≥ 1024);Dockerfile 由你提供。
|
|
113
|
+
- 网页端「应用详情 → 构建」Tab 也能选项目文件夹上传,效果一致。
|
|
114
|
+
|
|
93
115
|
### 生命周期
|
|
94
116
|
|
|
95
117
|
| 命令 | 说明 |
|
package/package.json
CHANGED
package/src/build.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// 无 Docker 构建:打包本地目录 → 直传 OSS → 平台入队 → 跟随日志直至终态。
|
|
2
|
+
//
|
|
3
|
+
// 字节全程不经平台 Pod:上传走平台开的预签名 PUT(OSS 公网直传),Runner 拉取走内网预签名 GET。
|
|
4
|
+
// 日志用游标轮询(?after=seq),断线可用 `nae build-logs <job>` 从任意位置续看。
|
|
5
|
+
import { request, NetworkError } from './http.js'
|
|
6
|
+
import { pack } from './tar.js'
|
|
7
|
+
import * as out from './output.js'
|
|
8
|
+
|
|
9
|
+
const CONTEXT_MAX = 200 * 1024 * 1024 // 与平台 enqueue 时的 HEAD 校验一致(200MB)
|
|
10
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'expired', 'canceled'])
|
|
11
|
+
|
|
12
|
+
// runBuild `nae build <appid> [path]` 主流程。
|
|
13
|
+
export async function runBuild(cfg, appid, contextPath, opts) {
|
|
14
|
+
const dockerfile = (opts.dockerfile || 'Dockerfile').trim()
|
|
15
|
+
const context = contextPath || '.'
|
|
16
|
+
|
|
17
|
+
// 预检:应用存在且为普通应用(只有普通应用走镜像推送链路)。
|
|
18
|
+
const app = await request(cfg, 'GET', `/apps/${appid}`)
|
|
19
|
+
if (app && app.Kind && app.Kind !== 'normal') {
|
|
20
|
+
throw new Error(`应用 ${appid} 是模板应用,不支持构建上传(仅普通应用可用)`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 打包(含 .dockerignore 过滤)。
|
|
24
|
+
out.info(`打包构建上下文:${context}(Dockerfile=${dockerfile})…`)
|
|
25
|
+
const { buffer, fileCount, rawSize, skippedSpecial } = pack(context, dockerfile)
|
|
26
|
+
if (skippedSpecial > 0) out.info(`(跳过 ${skippedSpecial} 个符号链接/特殊文件)`)
|
|
27
|
+
out.info(`打包完成:${fileCount} 文件,原始 ${humanSize(rawSize)} → 压缩 ${humanSize(buffer.length)}`)
|
|
28
|
+
if (buffer.length > CONTEXT_MAX) {
|
|
29
|
+
throw new Error(`压缩后 ${humanSize(buffer.length)} 超过 200MB 上限——请用 .dockerignore 排除 node_modules/构建产物等`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 建任务 → 拿预签名上传 URL。
|
|
33
|
+
const created = await request(cfg, 'POST', `/apps/${appid}/builds`, {
|
|
34
|
+
body: { tag: opts.tag, dockerfile },
|
|
35
|
+
})
|
|
36
|
+
const job = created.job
|
|
37
|
+
const uploadURL = created.upload_url
|
|
38
|
+
out.info(`已创建构建任务 #${job.ID}(tag=${job.Tag})`)
|
|
39
|
+
|
|
40
|
+
// 直传 OSS(不经平台)。预签名 PUT 签名用空 Content-Type,故不带任何 Content-Type/MD5 头。
|
|
41
|
+
out.info(`上传上下文到对象存储…`)
|
|
42
|
+
await putContext(uploadURL, buffer)
|
|
43
|
+
|
|
44
|
+
// 确认入队(平台 HEAD 验对象大小)。
|
|
45
|
+
const queued = await request(cfg, 'POST', `/builds/${job.ID}/enqueue`)
|
|
46
|
+
const ahead = queued && queued.position ? `,前面还有 ${queued.position} 个任务` : ''
|
|
47
|
+
out.info(`已入队${ahead}。任务 #${job.ID}`)
|
|
48
|
+
|
|
49
|
+
if (opts.follow === false) {
|
|
50
|
+
return { job_id: job.ID, status: queued ? queued.Status : 'queued' }
|
|
51
|
+
}
|
|
52
|
+
out.info(`跟随构建日志(Ctrl-C 停止跟随,构建仍在后台进行)…`)
|
|
53
|
+
return await followBuild(cfg, job.ID)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// putContext 用 fetch 直接 PUT 到预签名 URL。body 传 Uint8Array,避免 fetch 自动加 Content-Type
|
|
57
|
+
// (签名以空 Content-Type 计算,加了就 SignatureDoesNotMatch)。
|
|
58
|
+
async function putContext(url, buffer) {
|
|
59
|
+
const body = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
60
|
+
let resp
|
|
61
|
+
try {
|
|
62
|
+
resp = await fetch(url, { method: 'PUT', body })
|
|
63
|
+
} catch (e) {
|
|
64
|
+
throw new NetworkError(`上传到对象存储失败(${e.message})`)
|
|
65
|
+
}
|
|
66
|
+
if (!resp.ok) {
|
|
67
|
+
const text = await resp.text().catch(() => '')
|
|
68
|
+
throw new Error(`上传到对象存储失败:HTTP ${resp.status} ${text.slice(0, 300)}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// followBuild 游标轮询日志 + 状态,直至终态。返回终态摘要;非成功以退出码 1 结束进程。
|
|
73
|
+
async function followBuild(cfg, jobID) {
|
|
74
|
+
let after = 0
|
|
75
|
+
let stopping = false
|
|
76
|
+
const onSigint = () => {
|
|
77
|
+
stopping = true
|
|
78
|
+
out.info(`\n已停止跟随。构建仍在后台进行,可用 nae build-logs ${jobID} -f 续看,或 nae builds 查状态。`)
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
81
|
+
process.on('SIGINT', onSigint)
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
for (;;) {
|
|
85
|
+
after = await drainLogs(cfg, jobID, after)
|
|
86
|
+
const job = await request(cfg, 'GET', `/builds/${jobID}`)
|
|
87
|
+
if (TERMINAL.has(job.Status)) {
|
|
88
|
+
after = await drainLogs(cfg, jobID, after) // 收尾把剩余日志读干净
|
|
89
|
+
out.info(`\n构建结束:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
|
|
90
|
+
process.off('SIGINT', onSigint)
|
|
91
|
+
if (job.Status !== 'succeeded') process.exit(1)
|
|
92
|
+
return { job_id: jobID, status: job.Status }
|
|
93
|
+
}
|
|
94
|
+
await sleep(2000)
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (stopping) return
|
|
98
|
+
throw e
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// drainLogs 拉出 seq>after 的所有分片打到 stdout,返回新游标。
|
|
103
|
+
async function drainLogs(cfg, jobID, after) {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const chunks = await request(cfg, 'GET', `/builds/${jobID}/logs`, { query: { after } })
|
|
106
|
+
if (!Array.isArray(chunks) || chunks.length === 0) return after
|
|
107
|
+
for (const ch of chunks) {
|
|
108
|
+
process.stdout.write(ch.Content)
|
|
109
|
+
after = ch.Seq
|
|
110
|
+
}
|
|
111
|
+
if (chunks.length < 1000) return after // 未满页=已读到尾
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// followLogs `nae build-logs <job> [-f]`:单独跟随某任务日志(断线续看入口)。
|
|
116
|
+
export async function followLogs(cfg, jobID, opts) {
|
|
117
|
+
let after = 0
|
|
118
|
+
after = await drainLogs(cfg, jobID, after)
|
|
119
|
+
if (!opts.follow) {
|
|
120
|
+
const job = await request(cfg, 'GET', `/builds/${jobID}`)
|
|
121
|
+
out.info(`\n状态:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
process.on('SIGINT', () => process.exit(0))
|
|
125
|
+
for (;;) {
|
|
126
|
+
const job = await request(cfg, 'GET', `/builds/${jobID}`)
|
|
127
|
+
if (TERMINAL.has(job.Status)) {
|
|
128
|
+
after = await drainLogs(cfg, jobID, after)
|
|
129
|
+
out.info(`\n构建结束:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
|
|
130
|
+
if (job.Status !== 'succeeded') process.exit(1)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
after = await drainLogs(cfg, jobID, after)
|
|
134
|
+
await sleep(2000)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function statusLabel(s) {
|
|
139
|
+
return (
|
|
140
|
+
{
|
|
141
|
+
pending_upload: '等待上传',
|
|
142
|
+
queued: '排队中',
|
|
143
|
+
building: '构建中',
|
|
144
|
+
succeeded: '成功',
|
|
145
|
+
failed: '失败',
|
|
146
|
+
expired: '已过期',
|
|
147
|
+
canceled: '已取消',
|
|
148
|
+
}[s] || s
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function humanSize(n) {
|
|
153
|
+
if (n < 1024) return `${n} B`
|
|
154
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
155
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sleep(ms) {
|
|
159
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
160
|
+
}
|
package/src/forward.js
CHANGED
|
@@ -126,7 +126,11 @@ export async function forwardLLM(cfg, opts) {
|
|
|
126
126
|
// bridge 一条本地 TCP 连接 ↔ 一条平台 WS 隧道:本地数据缓冲到隧道就绪后发,回包写回本地。
|
|
127
127
|
function bridge(cfg, path, sock, cb) {
|
|
128
128
|
const ws = new WebSocket(wsBase(cfg) + path, {
|
|
129
|
-
headers: {
|
|
129
|
+
headers: {
|
|
130
|
+
'User-Agent': `nae-cli/${CLI_VERSION}`,
|
|
131
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
132
|
+
'X-NAE-CLI-Version': CLI_VERSION,
|
|
133
|
+
},
|
|
130
134
|
})
|
|
131
135
|
ws.binaryType = 'nodebuffer'
|
|
132
136
|
|
package/src/http.js
CHANGED
|
@@ -49,7 +49,12 @@ export async function request(cfg, method, path, opts = {}) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// User-Agent 让审计日志一眼认出是 CLI 及版本(nae-cli/<ver>);X-NAE-CLI-Version 仍留着喂
|
|
53
|
+
// APIKey.LastCLIVersion(CLI 配置页展示),两者各司其职、别删。
|
|
54
|
+
const headers = {
|
|
55
|
+
'User-Agent': `nae-cli/${CLI_VERSION}`,
|
|
56
|
+
'X-NAE-CLI-Version': CLI_VERSION,
|
|
57
|
+
}
|
|
53
58
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
54
59
|
let body
|
|
55
60
|
if (opts.body !== undefined) {
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { CLI_VERSION } from './version.js'
|
|
|
9
9
|
import { request, AuthError, NetworkError } from './http.js'
|
|
10
10
|
import { runLogin } from './login.js'
|
|
11
11
|
import { streamLogs } from './logs.js'
|
|
12
|
+
import { runBuild, followLogs } from './build.js'
|
|
12
13
|
import { forwardApp, forwardLLM } from './forward.js'
|
|
13
14
|
import * as out from './output.js'
|
|
14
15
|
|
|
@@ -370,6 +371,52 @@ program
|
|
|
370
371
|
})
|
|
371
372
|
)
|
|
372
373
|
|
|
374
|
+
// --- 构建(无 Docker:把本地目录交平台代构建镜像)---
|
|
375
|
+
|
|
376
|
+
program
|
|
377
|
+
.command('build <appid> [path]')
|
|
378
|
+
.description(
|
|
379
|
+
'打包本地目录上传,由平台构建 Runner 代跑 docker build/push(适合本机无 Docker)。默认跟随日志直至结束'
|
|
380
|
+
)
|
|
381
|
+
.option('--tag <tag>', '目标镜像 tag(缺省 latest)')
|
|
382
|
+
.option('--dockerfile <path>', '上下文内 Dockerfile 相对路径(缺省 Dockerfile)')
|
|
383
|
+
.option('--no-follow', '入队后立即返回,不跟随日志(用 nae build-logs <job> -f 再看)')
|
|
384
|
+
.addHelpText(
|
|
385
|
+
'after',
|
|
386
|
+
`
|
|
387
|
+
说明:
|
|
388
|
+
- 打包遵循上下文根的 .dockerignore(node_modules/构建产物请务必排除);压缩后上限 200MB。
|
|
389
|
+
- 上传直传对象存储、构建在仓库机上进行;构建成功推镜像后平台自动部署(与你本地 push 等效)。
|
|
390
|
+
- 排队:Runner 忙时任务排队等待,日志会显示排队位置。
|
|
391
|
+
|
|
392
|
+
示例:
|
|
393
|
+
nae build myweb # 打包当前目录,构建并跟随日志
|
|
394
|
+
nae build myweb ./service --tag v2 # 指定上下文目录与 tag
|
|
395
|
+
nae build myweb --dockerfile docker/Dockerfile.prod
|
|
396
|
+
nae build myweb --no-follow # 只入队,稍后 nae build-logs 看`
|
|
397
|
+
)
|
|
398
|
+
.action(
|
|
399
|
+
run(async (appid, pathArg, opts) => {
|
|
400
|
+
const res = await runBuild(cfg(), appid, pathArg, opts)
|
|
401
|
+
if (res) emit(res)
|
|
402
|
+
})
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
program
|
|
406
|
+
.command('builds <appid>')
|
|
407
|
+
.description('列出某应用最近的构建任务(含状态与排队位置)')
|
|
408
|
+
.action(run(async (appid) => emit(await request(cfg(), 'GET', `/apps/${appid}/builds`))))
|
|
409
|
+
|
|
410
|
+
program
|
|
411
|
+
.command('build-logs <jobid>')
|
|
412
|
+
.description('查看某构建任务的日志(游标续读;--follow 跟随至结束)')
|
|
413
|
+
.option('-f, --follow', '持续跟随直到构建结束')
|
|
414
|
+
.action(
|
|
415
|
+
run(async (jobid, opts) => {
|
|
416
|
+
await followLogs(cfg(), jobid, opts)
|
|
417
|
+
})
|
|
418
|
+
)
|
|
419
|
+
|
|
373
420
|
// --- 运行态:pods / events / metrics / logs ---
|
|
374
421
|
|
|
375
422
|
program
|
package/src/tar.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 零依赖构建上下文打包器:遍历目录 → 应用 .dockerignore → USTAR tar → gzip。
|
|
2
|
+
//
|
|
3
|
+
// 为什么手写而不引 tar 依赖:本 CLI 刻意只带 commander/ws 两个运行依赖。tar 格式很稳,
|
|
4
|
+
// USTAR 头 + gzip 百来行可控,胜过再拉一棵依赖树。docker build - 原生吃 tar.gz 作上下文。
|
|
5
|
+
import { statSync, lstatSync, readdirSync, readFileSync, existsSync } from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import zlib from 'zlib'
|
|
8
|
+
|
|
9
|
+
// loadDockerignore 读取并解析 <dir>/.dockerignore,返回模式数组(保序,后匹配覆盖前者)。
|
|
10
|
+
// 支持常见形态:注释(#)/空行忽略、前导 ! 取反(重新包含)、* 不跨 /、** 跨目录、? 单字符。
|
|
11
|
+
function loadDockerignore(dir) {
|
|
12
|
+
const f = path.join(dir, '.dockerignore')
|
|
13
|
+
if (!existsSync(f)) return []
|
|
14
|
+
const patterns = []
|
|
15
|
+
for (let line of readFileSync(f, 'utf8').split('\n')) {
|
|
16
|
+
line = line.trim()
|
|
17
|
+
if (line === '' || line.startsWith('#')) continue
|
|
18
|
+
let negate = false
|
|
19
|
+
if (line.startsWith('!')) {
|
|
20
|
+
negate = true
|
|
21
|
+
line = line.slice(1).trim()
|
|
22
|
+
}
|
|
23
|
+
line = line.replace(/^\/+/, '').replace(/\/+$/, '') // 去首尾斜杠,规整
|
|
24
|
+
if (line === '') continue
|
|
25
|
+
patterns.push({ re: patternToRegExp(line), negate })
|
|
26
|
+
}
|
|
27
|
+
return patterns
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// patternToRegExp 把单个 .dockerignore 模式编译为锚定正则。
|
|
31
|
+
// 逐字符转义,仅 * ? ** 特殊:** → 任意(含/),* → 非/任意,? → 单个非/。
|
|
32
|
+
function patternToRegExp(pat) {
|
|
33
|
+
let re = '^'
|
|
34
|
+
for (let i = 0; i < pat.length; i++) {
|
|
35
|
+
const ch = pat[i]
|
|
36
|
+
if (ch === '*') {
|
|
37
|
+
if (pat[i + 1] === '*') {
|
|
38
|
+
re += '.*'
|
|
39
|
+
i++
|
|
40
|
+
} else {
|
|
41
|
+
re += '[^/]*'
|
|
42
|
+
}
|
|
43
|
+
} else if (ch === '?') {
|
|
44
|
+
re += '[^/]'
|
|
45
|
+
} else {
|
|
46
|
+
re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return new RegExp(re + '$')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// isIgnored 判定 relPath(posix 斜杠)是否被排除。
|
|
53
|
+
// 对每个祖先前缀逐一试匹配,任一命中即按该模式的 negate 定夺;保序、后者覆盖前者
|
|
54
|
+
// (与 docker 语义一致:node_modules 排掉整棵子树,随后 !node_modules/keep 可挖回)。
|
|
55
|
+
function isIgnored(relPath, patterns) {
|
|
56
|
+
if (patterns.length === 0) return false
|
|
57
|
+
const parts = relPath.split('/')
|
|
58
|
+
let ignored = false
|
|
59
|
+
for (const { re, negate } of patterns) {
|
|
60
|
+
let hit = false
|
|
61
|
+
let prefix = ''
|
|
62
|
+
for (let i = 0; i < parts.length; i++) {
|
|
63
|
+
prefix = i === 0 ? parts[0] : prefix + '/' + parts[i]
|
|
64
|
+
if (re.test(prefix)) {
|
|
65
|
+
hit = true
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (hit) ignored = !negate
|
|
70
|
+
}
|
|
71
|
+
return ignored
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// walk 递归收集普通文件(相对 root 的 posix 路径 + 绝对路径 + 大小),跳过被忽略项与非常规文件。
|
|
75
|
+
// dockerfileRel 恒被包含(docker 不允许 .dockerignore 排掉 Dockerfile 自身)。
|
|
76
|
+
function walk(root, patterns, dockerfileRel) {
|
|
77
|
+
const files = []
|
|
78
|
+
let skippedSpecial = 0
|
|
79
|
+
|
|
80
|
+
function recur(absDir, relDir) {
|
|
81
|
+
for (const name of readdirSync(absDir)) {
|
|
82
|
+
const abs = path.join(absDir, name)
|
|
83
|
+
const rel = relDir ? relDir + '/' + name : name
|
|
84
|
+
|
|
85
|
+
let st
|
|
86
|
+
try {
|
|
87
|
+
st = lstatSync(abs)
|
|
88
|
+
} catch {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const keep = rel === dockerfileRel
|
|
92
|
+
if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile())) {
|
|
93
|
+
if (!keep) {
|
|
94
|
+
skippedSpecial++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (st.isDirectory()) {
|
|
99
|
+
// 目录被忽略则整棵剪枝(除非里头就是 Dockerfile,罕见,简化为不剪)。
|
|
100
|
+
if (isIgnored(rel, patterns) && !dockerfileRel.startsWith(rel + '/')) continue
|
|
101
|
+
recur(abs, rel)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (!keep && isIgnored(rel, patterns)) continue
|
|
105
|
+
files.push({ abs, rel, size: st.size })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
recur(root, '')
|
|
110
|
+
return { files, skippedSpecial }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- USTAR 写入 ---
|
|
114
|
+
|
|
115
|
+
function writeOctal(buf, val, off, len) {
|
|
116
|
+
// (len-1) 位前导零八进制 + NUL,经典 USTAR 数字域。
|
|
117
|
+
const s = val.toString(8).padStart(len - 1, '0') + '\0'
|
|
118
|
+
buf.write(s, off, len, 'ascii')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// tarHeader 造一个 512 字节 USTAR 头。长名用 prefix(155)+name(100) 拆分。
|
|
122
|
+
function tarHeader(name, size, mtime) {
|
|
123
|
+
const buf = Buffer.alloc(512)
|
|
124
|
+
|
|
125
|
+
// 名称:≤100 直接放 name;否则在某个 / 处拆成 prefix(≤155)+name(≤100)。
|
|
126
|
+
let prefix = ''
|
|
127
|
+
let fname = name
|
|
128
|
+
if (Buffer.byteLength(name) > 100) {
|
|
129
|
+
const idx = splitName(name)
|
|
130
|
+
if (idx < 0) throw new Error(`路径过长,tar 无法编码(请用 .dockerignore 排除):${name}`)
|
|
131
|
+
prefix = name.slice(0, idx)
|
|
132
|
+
fname = name.slice(idx + 1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
buf.write(fname, 0, 100, 'utf8')
|
|
136
|
+
writeOctal(buf, 0o644, 100, 8) // mode
|
|
137
|
+
writeOctal(buf, 0, 108, 8) // uid
|
|
138
|
+
writeOctal(buf, 0, 116, 8) // gid
|
|
139
|
+
writeOctal(buf, size, 124, 12) // size
|
|
140
|
+
writeOctal(buf, mtime, 136, 12) // mtime
|
|
141
|
+
buf.write(' ', 148, 8, 'ascii') // chksum 占位=空格
|
|
142
|
+
buf.write('0', 156, 1, 'ascii') // typeflag 普通文件
|
|
143
|
+
buf.write('ustar\0', 257, 6, 'ascii') // magic
|
|
144
|
+
buf.write('00', 263, 2, 'ascii') // version
|
|
145
|
+
if (prefix) buf.write(prefix, 345, 155, 'utf8')
|
|
146
|
+
|
|
147
|
+
// 校验和:全头字节和(chksum 域按空格计),写 6 位八进制 + NUL + 空格。
|
|
148
|
+
let sum = 0
|
|
149
|
+
for (let i = 0; i < 512; i++) sum += buf[i]
|
|
150
|
+
buf.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii')
|
|
151
|
+
return buf
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// splitName 找一个 / 位置把长名拆成 prefix(≤155)/name(≤100)。找不到返回 -1。
|
|
155
|
+
function splitName(name) {
|
|
156
|
+
for (let i = name.length - 101; i >= 0; i--) {
|
|
157
|
+
if (name[i] === '/' && Buffer.byteLength(name.slice(0, i)) <= 155 && Buffer.byteLength(name.slice(i + 1)) <= 100) {
|
|
158
|
+
return i
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return -1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const PAD = Buffer.alloc(512)
|
|
165
|
+
|
|
166
|
+
// pack 打包 contextDir 为 tar.gz Buffer。dockerfileRel 是上下文内 Dockerfile 相对路径。
|
|
167
|
+
// 返回 { buffer, fileCount, rawSize, skippedSpecial }。
|
|
168
|
+
export function pack(contextDir, dockerfileRel) {
|
|
169
|
+
const root = path.resolve(contextDir)
|
|
170
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
171
|
+
throw new Error(`构建上下文不是目录:${root}`)
|
|
172
|
+
}
|
|
173
|
+
const dfRel = dockerfileRel.split(path.sep).join('/')
|
|
174
|
+
if (!existsSync(path.join(root, dfRel))) {
|
|
175
|
+
throw new Error(`Dockerfile 不存在:${path.join(root, dfRel)}(用 --dockerfile 指定相对路径)`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const patterns = loadDockerignore(root)
|
|
179
|
+
const { files, skippedSpecial } = walk(root, patterns, dfRel)
|
|
180
|
+
if (files.length === 0) throw new Error('构建上下文为空(是否被 .dockerignore 全部排除了?)')
|
|
181
|
+
|
|
182
|
+
const parts = []
|
|
183
|
+
let rawSize = 0
|
|
184
|
+
const mtime = Math.floor(Date.now() / 1000)
|
|
185
|
+
for (const f of files) {
|
|
186
|
+
const content = readFileSync(f.abs)
|
|
187
|
+
parts.push(tarHeader(f.rel, content.length, mtime))
|
|
188
|
+
parts.push(content)
|
|
189
|
+
const rem = content.length % 512
|
|
190
|
+
if (rem !== 0) parts.push(PAD.subarray(0, 512 - rem))
|
|
191
|
+
rawSize += content.length
|
|
192
|
+
}
|
|
193
|
+
parts.push(PAD, PAD) // 两个空块收尾
|
|
194
|
+
|
|
195
|
+
const tar = Buffer.concat(parts)
|
|
196
|
+
const buffer = zlib.gzipSync(tar, { level: 6 })
|
|
197
|
+
return { buffer, fileCount: files.length, rawSize, skippedSpecial }
|
|
198
|
+
}
|