yj-deploy 0.0.6 → 1.0.0

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/lib/main.js +560 -0
  3. package/package.json +5 -10
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  - ✔ 支持手动上传其他任何类型项目
11
11
  - ✔ 支持灵活配置dockerfile,upload.sh等脚本内容
12
12
  - ✔ 支持多种方式配置 `镜像仓库` `镜像环境`等参数
13
+ - ✔ 支持懒上传,仅上传新增部分文件,提升上传效率
13
14
  > 全程不需要连接跳板机做任何操作即可完成镜像推送
14
15
 
15
16
  ## 运行情况
package/lib/main.js ADDED
@@ -0,0 +1,560 @@
1
+ import { stdout } from 'single-line-log'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { Client } from 'ssh2'
5
+
6
+
7
+ export default class yjDeploy {
8
+ config = {
9
+ host: '',
10
+ port: '',
11
+ username: '',
12
+ password: '',
13
+ namespace: '', // 项目命名空间,等同于镜像地址目录名称
14
+ imageStore: 'dev-images', // 镜像仓库
15
+ tmpName: 'dev', // 镜像环境
16
+ delay: 0, // 延迟上传时间
17
+ // 本地文件目录(必填)
18
+ fileDir: '',
19
+ // 远程sftp服务根目录
20
+ rootDir: '/home/yjweb',
21
+
22
+ // dockerfile文件信息
23
+ dockerfile: {
24
+ name: 'Dockerfile',
25
+ content: [
26
+ 'FROM harbor.yunjingtech.cn:30002/yj-base/nginx:latest',
27
+ 'RUN rm -rf /usr/share/nginx/html/*',
28
+ 'COPY dist /usr/share/nginx/html/'
29
+ ]
30
+ },
31
+
32
+ // upload.sh文件信息
33
+ upload: {
34
+ name: 'upload.sh',
35
+ content: [
36
+ '#!/bin/sh',
37
+ 'tag=`basename \\`pwd\\``:$2-`date +%Y%m%d`',
38
+ 'echo "- 镜像仓库: $1"',
39
+ 'echo "- 镜像环境: $2"',
40
+ 'docker build -t harbor.yunjingtech.cn:30002/$1/$tag .',
41
+ 'docker push harbor.yunjingtech.cn:30002/$1/$tag',
42
+ 'echo - 镜像地址: harbor.yunjingtech.cn:30002/$1/$tag',
43
+ ]
44
+ },
45
+
46
+ // 上传dist目录信息
47
+ dist: {
48
+ name: 'dist'
49
+ },
50
+
51
+ parallelDir: 20, // 文件夹并行创建数量,如果报错,可以减少此配置
52
+
53
+ parallelFile: 50, // 文件并行上传数量,如果报错,可以减少此配置
54
+
55
+ allLog: false, // 是否显示全部日志
56
+
57
+ lazyUpload: false // 是否开启懒上传
58
+ }
59
+
60
+ ssh2Conn = null
61
+ // 上传状态
62
+ uploading = false
63
+ // 定时器
64
+ trim = null
65
+
66
+ constructor(config) {
67
+ this.config = Object.assign(this.config, config)
68
+
69
+ return {
70
+ name: 'yj-deploy',
71
+ isPut: this.isPut.bind(this),
72
+ upload: this.upload.bind(this),
73
+ // webpack钩子
74
+ apply: this.apply.bind(this),
75
+ // vite上传钩子
76
+ closeBundle: this.isPut.bind(this)
77
+ }
78
+ }
79
+
80
+ // webpack钩子
81
+ apply(compiler) {
82
+ if (compiler && compiler.hooks && compiler.hooks.done) {
83
+ compiler.hooks.done.tap('yj-deploy', () => {
84
+ this.isPut()
85
+ })
86
+ }
87
+ return 'build'
88
+ }
89
+
90
+ /**
91
+ * 判断是否需要上传,
92
+ * vite和 webpack环境在每次编译完都会触发当前方法
93
+ * 需要通过是否有命令行参数 --deploy
94
+ */
95
+ isPut () {
96
+ const deploy = this.getArgv('--deploy')
97
+ if(deploy != undefined) {
98
+ clearTimeout(this.trim)
99
+ this.trim = setTimeout(() => {
100
+ this.upload() // 开始上传逻辑
101
+ }, this.config.delay || 0)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 连接跳板机并开始上传
107
+ */
108
+ upload() {
109
+ console.log('-------------- deploy-start --------------')
110
+
111
+ if(!this.config.host) {
112
+ console.log('- 请配置跳板机地址 host')
113
+ return
114
+ }
115
+
116
+ if(!this.config.port) {
117
+ console.log('- 请配置跳板机端口 port')
118
+ return
119
+ }
120
+ if(!this.config.username) {
121
+ console.log('- 请配置跳板机账号 username')
122
+ return
123
+ }
124
+ if(!this.config.password) {
125
+ console.log('- 请配置跳板机密码 password')
126
+ return
127
+ }
128
+
129
+ if(this.uploading) {
130
+ return
131
+ }
132
+
133
+ this.ssh2Conn = new Client()
134
+ this.ssh2Conn
135
+ .on('ready', () => {
136
+ console.log('- 跳板机连接成功!')
137
+
138
+ this.ssh2Conn.sftp(async (err, sftp) => {
139
+ if (err) {
140
+ console.log('- sftp连接失败')
141
+ this.breakConnect()
142
+ }
143
+
144
+ if (!this.config.namespace) {
145
+ console.log('- 请配置项目命名空间 namespace')
146
+ this.breakConnect()
147
+ }
148
+
149
+ this.uploading = true
150
+
151
+ // 是否重置项目
152
+ if(this.getArgv('--reset') !== undefined) {
153
+ console.log('- 重置项目')
154
+ await this.onShell(`rm -r ${this.config.rootDir}/${this.config.namespace}`)
155
+ }
156
+
157
+ // 上传项目
158
+ this.upLoadProject(sftp, `${this.config.rootDir}/${this.config.namespace}`)
159
+ })
160
+ })
161
+ .connect({
162
+ host: this.config.host,
163
+ port: this.config.port,
164
+ username: this.config.username,
165
+ password: this.config.password
166
+ })
167
+
168
+ this.ssh2Conn.on('error', (error) => {
169
+ console.log(`- 连接失败: ${error}`)
170
+ this.breakConnect()
171
+ })
172
+
173
+ this.ssh2Conn.on('end', () => {
174
+ this.uploading = false
175
+ })
176
+ }
177
+
178
+ /**
179
+ * 断开连接
180
+ */
181
+ breakConnect() {
182
+ this.uploading = false
183
+ console.log('- 已断开连接')
184
+ console.log('-------------- deploy-end --------------')
185
+ this.ssh2Conn.end()
186
+ }
187
+
188
+ /**
189
+ * 运行shell命令
190
+ * @param {*} shell
191
+ * @returns Promise
192
+ */
193
+ onShell(shell) {
194
+ const envIsAllLog = this.getArgv('--allLog') !== undefined
195
+
196
+ return new Promise((resolve, reject) => {
197
+ this.ssh2Conn.shell((err, stream) => {
198
+ if (err) {
199
+ console.log('- 远程命令错误:' + err, shell)
200
+ this.breakConnect()
201
+ reject(err)
202
+ return
203
+ }
204
+ let dataList = []
205
+ stream
206
+ .on('close', () => {
207
+ // 远程命令执行完毕
208
+ resolve(dataList.toString())
209
+ })
210
+ .on('data', (data) => {
211
+ let str = data.toString()
212
+ if(envIsAllLog || this.config.allLog) {
213
+ dataList.push('\n' + str)
214
+ } else if(str.startsWith('- ')) {
215
+ dataList.push('\n' + str)
216
+ }
217
+ })
218
+ .stderr.on('data', (data) => {
219
+ console.log('- 远程命令错误:\n' + data)
220
+ this.breakConnect()
221
+ reject(data)
222
+ })
223
+ stream.end(shell + '\nexit\n')
224
+ })
225
+ })
226
+ }
227
+
228
+ /**
229
+ * 初始化项目
230
+ * @param {*} sftp
231
+ * @param {*} dir
232
+ */
233
+ projectInit(sftp, dir) {
234
+ return new Promise(async (resolve, reject) => {
235
+ // 创建项目目录
236
+ sftp.mkdir(dir, async (err) => {
237
+ if (err) {
238
+ reject(err)
239
+ } else {
240
+ // 创建dockerfile
241
+ console.log('- 正在创建dockerfile')
242
+ const dockerfile = `${dir}/${this.config.dockerfile.name}`
243
+ await this.onShell(`touch ${dockerfile}`)
244
+ await this.onShell(`echo '${this.config.dockerfile.content.join('\n')}' > ${dockerfile}`)
245
+
246
+ // 创建upload.sh脚本
247
+ console.log('- 正在创建upload.sh')
248
+ const uploadSh = `${dir}/${this.config.upload.name}`
249
+ await this.onShell(`touch ${uploadSh}`)
250
+ await this.onShell(`echo '${this.config.upload.content.join('\n')}' > ${uploadSh}`)
251
+
252
+ resolve()
253
+ }
254
+ })
255
+ })
256
+ }
257
+
258
+
259
+ /**
260
+ * 判断文件是否存在
261
+ * @param {*} sftp
262
+ * @param {*} remotePath
263
+ * @returns
264
+ */
265
+ checkRemoteFile(sftp, remotePath) {
266
+ return new Promise((resolve) => {
267
+ sftp.stat(remotePath, (err) => {
268
+ if (err) {
269
+ resolve(false) // 文件不存在
270
+ } else {
271
+ resolve(true) // 文件存在
272
+ }
273
+ })
274
+ })
275
+ }
276
+
277
+ /**
278
+ * 上传项目
279
+ * @param {*} sftp
280
+ * @param {*} dir
281
+ */
282
+ upLoadProject(sftp, dir) {
283
+ sftp.readdir(dir, async (err) => {
284
+ if (err) {
285
+ // 不存在目录
286
+ console.log('- 跳板机不存在项目, 开始创建项目')
287
+ this.projectInit(sftp, dir)
288
+ .then(() => {
289
+ // 目录已创建,再次调用上传
290
+ console.log(`- ${this.config.namespace}项目创建成功`)
291
+ this.upLoadProject(sftp, dir)
292
+ })
293
+ .catch((err) => {
294
+ console.log(`- 创建项目失败: ${err}`)
295
+ this.breakConnect()
296
+ })
297
+ return
298
+ }
299
+
300
+ // 是否开启懒上传
301
+ const lazyUpload = (this.getArgv('--lazyUpload') !== undefined) || this.config.lazyUpload
302
+
303
+ const distDir = `${dir}/${this.config.dist.name}`
304
+ if(!lazyUpload) {
305
+ // 删除dist目录所有内容
306
+ await this.onShell(`rm -rf ${distDir}`)
307
+ }
308
+ // 再创建一个空的dist
309
+ await this.onShell(`mkdir ${distDir}`)
310
+
311
+
312
+ // 获取本地目录下所有文件
313
+ if(!this.config.fileDir) {
314
+ console.log('- 请配置待上传文件目录 fileDir')
315
+ this.breakConnect()
316
+ return
317
+ }
318
+
319
+ const { localFileList, dirList } = await this.getFilesInDirectory(this.config.fileDir)
320
+ if(localFileList.length === 0) {
321
+ console.log('- 待上传目录没有获取到文件,请检查')
322
+ this.breakConnect()
323
+ return
324
+ }
325
+
326
+ /**
327
+ * 上传文件或目录
328
+ */
329
+ let num = 0
330
+ let total = localFileList.length
331
+ // 上传文件
332
+ const uploadFile = (file) => {
333
+ return new Promise(async (resolve, reject) => {
334
+ try {
335
+
336
+ if(lazyUpload) {
337
+ const isExist = await this.checkRemoteFile(sftp, `${distDir}${file.remotePath}`)
338
+ if(isExist) {
339
+ return resolve() // 文件已存在,跳过上传
340
+ }
341
+ }
342
+
343
+ const readStream = fs.createReadStream(file.localPath)
344
+ const writeStream = sftp.createWriteStream(`${distDir}${file.remotePath}`)
345
+ writeStream.on('close', () => {
346
+ this.progressBar(++num, total)
347
+ resolve()
348
+ })
349
+ writeStream.on('error', (err) => {
350
+ console.log(`- 文件 ${file.remotePath} 上传失败:${err}`)
351
+ reject(err)
352
+ this.breakConnect()
353
+ })
354
+
355
+ readStream.pipe(writeStream)
356
+ } catch (error) {
357
+ reject(error)
358
+ }
359
+ })
360
+ }
361
+
362
+ /**
363
+ * 分割数组
364
+ */
365
+ const chunkArray = (array, chunkSize) => {
366
+ let result = [];
367
+ for (let i = 0; i < array.length; i += chunkSize) {
368
+ result.push(array.slice(i, i + chunkSize));
369
+ }
370
+ return result;
371
+ }
372
+
373
+ /**
374
+ * 串行执行上传文件
375
+ * @param {Array} promises
376
+ */
377
+ const sequentialPromises = (promises) => {
378
+ if (promises.length === 0) {
379
+ return Promise.resolve();
380
+ }
381
+ // 获取数组的第一个promise并执行
382
+ const firstPromise = promises.shift();
383
+ return Promise.all(firstPromise.map(item => uploadFile(item))).then(() => sequentialPromises(promises));
384
+ }
385
+
386
+ // 创建目录, 同时创建多个
387
+ const createDir = (promises) => {
388
+ if (promises.length === 0) {
389
+ return Promise.resolve();
390
+ }
391
+ // 获取数组的第一个promise并执行
392
+ const firstPromise = promises.shift();
393
+ let shell = firstPromise.map(remotePath => `${distDir}${remotePath}`).join(' ');
394
+ return this.onShell(`mkdir -p ${shell}`).then(() => createDir(promises));
395
+ }
396
+
397
+ // 先创建目录,将目录分层级创建,增加效率,防止目录过多时串行太慢
398
+ let dirGroup = [...chunkArray(dirList, this.config.parallelDir || 20)]
399
+ // console.log(`- 开始创建目录`)
400
+ await createDir(dirGroup)
401
+ console.log('- 创建目录完成')
402
+
403
+ // 创建文件夹后延迟一下再上传文件,偶尔会报错
404
+ await new Promise((resolve) => {
405
+ setTimeout(() => {
406
+ resolve()
407
+ }, 500)
408
+ })
409
+
410
+ // 再上传文件
411
+ // 根据配置文件并行上传数量分割,如果并行太多可能会报错
412
+ let fileList = [...chunkArray(localFileList, this.config.parallelFile || 50)]
413
+
414
+ // console.log('- 开始上传文件')
415
+ await sequentialPromises(fileList)
416
+
417
+ if(lazyUpload) {
418
+ this.progressBar(total, total)
419
+ console.log('\x1b[32m%s\x1b[0m', `- 已开启懒上传,本次共上传 ${num} 个文件`)
420
+ }
421
+ console.log('- 文件上传成功')
422
+
423
+ console.log('- 开始推送镜像')
424
+ let shell = `cd ${dir} && sh ${this.config.upload.name}`
425
+
426
+ // 判断是不是自动创建的脚本,手动创建的脚本不支持镜像仓库名称参数
427
+ const isNewShell = await this.isNewShell(sftp, dir)
428
+ if(isNewShell) {
429
+ // 添加镜像仓库名称
430
+ const imageStore = this.getArgv('--imageStore') || this.config.imageStore
431
+ shell += ` ${imageStore}`
432
+ } else {
433
+ console.log('\n- warning 检测到当前脚本为手动创建,不支持镜像仓库配置,imageStore将失效')
434
+ console.log('- warning 如需支持imageStore参数,请在命令行后添加 --reset 重新生成脚本')
435
+ }
436
+
437
+ // 添加镜像环境参数
438
+ const tmpName = this.getArgv('--tmpName') || this.config.tmpName
439
+ shell += ` ${tmpName}`
440
+
441
+ const imagePath = await this.onShell(shell) // 推送镜像
442
+ console.log(imagePath) // 推送镜像脚本返回输出
443
+ console.log('- 镜像推送完成')
444
+
445
+ this.breakConnect()
446
+ })
447
+ }
448
+
449
+ /**
450
+ * 获取脚本内容是不是自动创建的脚本
451
+ * 兼容用户手动根据运维文档创建的推送脚本,因参数不同可能报错的问题
452
+ * 有 $2代表是自动创建的版本,否则老版本
453
+ */
454
+ async isNewShell(sftp, dir) {
455
+ return new Promise((resolve, reject) => {
456
+ sftp.readFile(`${dir}/${this.config.upload.name}`, (err, data) => {
457
+ if(err) {
458
+ return reject(false)
459
+ }
460
+
461
+ const fileText = data.toString()
462
+ resolve(fileText.includes('$2'))
463
+ })
464
+ })
465
+ }
466
+
467
+
468
+ /**
469
+ * 进度条
470
+ * @param description 命令行开头的文字信息
471
+ * @param bar_length 进度条的长度(单位:字符),默认设为 25
472
+ */
473
+ progressBar(completed, total, bar_length = 25) {
474
+ let percent = (completed / total).toFixed(4) // 计算进度(子任务的 完成数 除以 总数)
475
+ let cell_num = Math.floor(percent * bar_length) // 计算需要多少个 █ 符号来拼凑图案 // 拼接黑色条
476
+ let cell = ''
477
+ for (let i = 0; i < cell_num; i++) {
478
+ cell += '█'
479
+ } // 拼接灰色条
480
+ let empty = ''
481
+ for (let i = 0; i < bar_length - cell_num; i++) {
482
+ empty += '░'
483
+ } // 拼接最终文本
484
+ let cmdText = `- 文件上传进度: ${cell}${empty} (${completed}/${total}) ${(
485
+ 100 * percent
486
+ ).toFixed(2)}%` // 在单行输出文本
487
+ stdout(cmdText)
488
+ if(completed == total) {
489
+ console.log('')
490
+ }
491
+ }
492
+
493
+ /**
494
+ * 获取目录下所有文件并分类
495
+ * @param {*} dir
496
+ */
497
+ getFilesInDirectory(directory) {
498
+ const fileList = [] // 文件夹
499
+ const dirList = [] // 最底层目录, 只需要获取最底层目录即可
500
+ return new Promise((resolve, reject) => {
501
+ const traverseFolder = (folderPath, parentFolder, level) => {
502
+ // 读取文件夹列表
503
+ const files = fs.readdirSync(folderPath)
504
+ let isDir = false // 当前文件夹内是否还有目录
505
+ // 遍历文件夹列表
506
+ files.forEach((fileName) => {
507
+ // 拼接当前文件路径
508
+ const filePath = path.join(folderPath, fileName)
509
+
510
+ // 获取文件类型
511
+ const stats = fs.statSync(filePath)
512
+ const isDirectory = stats.isDirectory()
513
+
514
+ if(!isDirectory) {
515
+ fileList.push({
516
+ localPath: filePath, // 本地路径
517
+ remotePath: `${parentFolder}${fileName}`, // 远程路径
518
+ level // 文件或目录层级
519
+ })
520
+ } else {
521
+ isDir = true
522
+ }
523
+
524
+ // 判断该路径是文件夹还是文件
525
+ if (isDirectory) {
526
+ // 如果是文件夹,递归遍历
527
+ traverseFolder(filePath, `${parentFolder}${fileName}/`, level + 1)
528
+ }
529
+ })
530
+
531
+ if(!isDir) {
532
+ dirList.push(parentFolder)
533
+ }
534
+ }
535
+
536
+ traverseFolder(directory, '/', 0)
537
+
538
+ resolve({
539
+ localFileList: fileList,
540
+ dirList
541
+ });
542
+ });
543
+ }
544
+
545
+ /**
546
+ * 获取命令行参数
547
+ * @param { string } name
548
+ */
549
+ getArgv(name) {
550
+ const argv = process.argv
551
+ let result = undefined
552
+ argv.forEach(item => {
553
+ if(item.indexOf(name) > -1) {
554
+ result = item.split('=')[1] || ''
555
+ }
556
+ })
557
+
558
+ return result
559
+ }
560
+ }
package/package.json CHANGED
@@ -1,23 +1,18 @@
1
1
  {
2
2
  "name": "yj-deploy",
3
- "version": "0.0.6",
3
+ "version": "1.0.0",
4
4
  "description": "ssh sftp",
5
+ "type": "module",
5
6
  "scripts": {
6
7
  "build": "vite build"
7
8
  },
8
9
  "files": [
9
- "dist",
10
+ "lib",
10
11
  "package.json",
11
12
  "README.md"
12
13
  ],
13
- "main": "./dist/yj-deploy.umd.js",
14
- "module": "./dist/yj-deploy.mjs",
15
- "exports": {
16
- ".": {
17
- "import": "./dist/yj-deploy.mjs",
18
- "require": "./dist/yj-deploy.umd.js"
19
- }
20
- },
14
+ "main": "./lib/main.js",
15
+ "module": "./lib/main.js",
21
16
  "author": "",
22
17
  "license": "ISC",
23
18
  "dependencies": {