背景 最近开发了越来越多的内部js
库, 库的版本管理就显得越来越重要了. 之前的做法是通过gitlab-ci
控制master
发布到gitlab-release
中, 但是随着库的数量的增长, 以及项目也需要做依赖库的版本管理的需求, 所以考虑搭建一个私有的cnpm
仓库来存储内部开发的js
库
调研 初步看到有两个比较常用的解决方案
cnpm
- 阿里出品
verdaccio
- 最近流行的开源方案
cnpm
的优点是稳定, 相关开发文档全面, 而且使用的用户也比较多. 而verdaccio
看起来比较新颖而且有比较多的新特性, 但是开发资料少, 而且很少见到有大型用户使用. 由于我们这个仓库只是作为内部库的registry
使用, 不太需要新功能以及人力来维护, 所以最终权衡后, 还是考虑更稳定和使用更广泛的cnpm
来搭建自己的私有仓库
实现 需求
开发私有registry
最基础的需求是前面两点: 稳定存储和权限控制. 然后出于维护成本的考虑, 希望能尽可能减少开发量和侵入性修改, 便于后期迭代升级, 也需要减少部署成本, 如更换服务器或迁移机房后重新部署起来不会麻烦
设计 分别解决以上需求的方案如下:
使用现有的CephFS
来解决稳定存储的问题, 虽然cnpm
也支持s3
存储, 但是在本地开发或测试环境使用s3
会比较麻烦, 因此这里直接使用CephFS
来存储, 它的体验和本地文件系统是一样的, 同时也是支持多副本存储和共享存储挂载的
研究了cnpm
的用户模块后, 发现只需要实现一个自己的user_service
来提供权限认证就可以了, 它已经定义好了需要实现的接口和结构
为了实现减少代码侵入, 将user_service
和scope
等管理接口额外抽离出一个独立服务来提供结构给cnpm
调用, 避免修改过多的cnpm
的代码导致维护困难
直接容器化部署, 可以很简单的扩缩容, 没有运维压力
开发 docker
化cnpm
已经提供了docker
化的方案, 自带Dockerfile
和dockerize/config
, 修改里面需要调整的内容即可. 本地开发可以直接修改docker-compose.yml
里的volume/mysql
等配置.
user_service
比较重要的就是这一块代码调整了, cnpm
默认使用的default_user_service
是直接读取config.js
里相关配置实现的, 我们需要同样实现一个user_service
, 只是将信息通过接口获取. 其中proto.auth
接口就是提供给客户端npm login
使用的, 传入参数是用户输入的username
和password
. 而我们这里为了避免用户泄露密码, 采用的是用户的登录token
去auth
平台校验, 避免用户密码泄露
1 2 3 4 5 6 7 var AuthUserService = require ('./auth_user_service' );config.userService = new AuthUserService(); config.customUserService = true ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 'use strict' ;var debug = require ('debug' )('cnpmjs.org:services:auth_user_service' );var request = require ('request' );var gravatar = require ('gravatar' );var User = require ('../models' ).User;var config = require ('../config' );module .exports = AuthUserService;const authRequest = async (options) => { return await new Promise ((resolve, reject ) => { request(options, function (error, response ) { if (error) { return reject(error); } if (response.statusCode !== 200 ) { return reject(response.statusCode); } const result = JSON .parse(response.body); return resolve(result); }); }); }; async function convertToUser (row ) { const userWithScopeInfo = await authRequest({ method : 'GET' , url : `${config.userServiceConfig.npmUserServiceAddr} ?name=${row.username} ` , headers : { 'X-TOKEN' : '// ...' , }, }); debug('userWithScopeInfo: ' , userWithScopeInfo); var user = { login : row.username, email : row.email, name : row.username, html_url : 'http://cnpmjs.org/~' + row.username, avatar_url : '' , im_url : `// ...` , site_admin : !!userWithScopeInfo.data.userInfo.is_super_admin, scopes : userWithScopeInfo.data.scopeList.map((x ) => x.id), }; if (row.json) { var data = row.json; if (data.login) { user = data; } else { if (data.avatar) { user.avatar_url = data.avatar; } if (data.fullname) { user.name = data.fullname; } if (data.homepage) { user.html_url = data.homepage; } if (data.twitter) { user.im_url = 'https://twitter.com/' + data.twitter; } } } if (!user.avatar_url) { user.avatar_url = gravatar.url(user.email, { s : '50' , d : 'retro' }, true ); } return user; } async function getUserInfoByName (name ) { try { const result = await authRequest({ method : 'GET' , url : `${config.userServiceConfig.npmUserServiceAddr} ?name=${name} ` , headers : { 'X-TOKEN' : '// ...' , }, }); if (!result || !result.data) { return null ; } return [].concat(result.data).find((x ) => x && x.username === name); } catch (e) { return null ; } } function AuthUserService ( ) {}var proto = AuthUserService.prototype;proto.auth = function * (login, password ) { var row = yield new Promise ((resolve ) => { request( { method : 'GET' , url : config.userServiceConfig.npmUserLoginAddr, headers : { 'X-TOKEN' : password, }, }, function (error, response ) { if (error) { return resolve(null ); } if (response.statusCode !== 200 ) { return resolve(null ); } const result = JSON .parse(response.body); if (!result || !result.data) { return resolve(null ); } if (result.data.user.username !== login) { return resolve(null ); } return resolve(result.data.user); }, ); }); if (!row) { return null ; } return yield convertToUser(row); }; proto.get = function * (login ) { var row = yield User.findByName(login); if (!row) { return null ; } var user = yield getUserInfoByName(row.name); if (!user) { return null ; } return yield convertToUser(user); }; proto.list = function * (logins ) { var rows = yield User.listByNames(logins); var users = []; for (var row of rows) { const user = yield getUserInfoByName(row.name); users.push(yield convertToUser(user)); } return users; }; proto.search = function * (query, options ) { options = options || {}; options.limit = parseInt (options.limit); if (!options.limit || options.limit < 0 ) { options.limit = 20 ; } var rows = yield User.search(query, options); var users = []; for (var row of rows) { const user = yield getUserInfoByName(row.name); users.push(yield convertToUser(user)); } return users; };
scope_service
完成user_service
之后, 私有npm
就可以使用了, 但是在上传包时, 发现每个用户只能上传固定scope
的包, 研究后发现同样是通过config
配置的, 所以同样修改为通过scope_service
实现, 同时user_service
的用户信息接口中也额外返回此用户有哪些scope
权限以及是否超管即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 'use strict' ;var request = require ('request' );var config = require ('../config' );const authRequest = async (options) => { return await new Promise ((resolve, reject ) => { request(options, function (error, response ) { if (error) { return reject(error); } if (response.statusCode !== 200 ) { return reject(response.statusCode); } const result = JSON .parse(response.body); return resolve(result); }); }); }; const scopeService = { async getAllScope ( ) { try { const result = await authRequest({ method : 'GET' , url : `${config.userServiceConfig.npmUserServiceHost} /api/v1/scope/list` , headers : { 'X-TOKEN' : '// ...' , }, }); if (!result || !result.data) { return []; } return result.data.scopeList.map((x ) => x.id); } catch (e) { return []; } }, }; module .exports = scopeService;
然后修改middleware/proxy_to_npm
和middleware/sync_by_install
里相关获取scope
的逻辑, 改为通过scope_service
获取
小结 外部auth_service
的实现就不用细讲了, 很简单的curd
实现即可, 特殊点的是为了方便, 我们直接使用cnpm
自带的mysql
的库就行
开发完成后, 通过k8s
将共享分布式存储CephFS
挂载到容器内data
目录下启动, 尝试上传包->重启容器->查看包是否能正常下载, 扩容缩容后服务均能正常运行和下载, 至此就完成了内部npm-registry
的搭建
其他 设置scope
方便使用 npm
现在支持指定特定scope
使用特定registry
来下载, 如果不使用此命令而是在下载时候指定--registry https://my.registry.com
, 偶尔会出现某些包被解析到这个registry
目录下但实际上不存在的情况, 测试后发现可能是npm
自己的bug
, 所以实践上建议通过scope
来指定registry
1 2 3 npm config set '@myscope:registry' https://my.registry.com npm install @myscope/lib
tag
使用在用其他包时如果想要使用最新特性, 往往会通过@next
等标签来获取最新版本, 内部发布测试的时候同样可以指定tag
来发布, 完成某些功能测试和提供先行体验版本等.
但是得注意的是, 即时是携带tag
的版本, 版本号同样不能与正式版本冲突(即版本号为唯一主键), 建议使用@1.0.0-next-{hash:8}
携带具体版本信息的字符串作为测试的版本号
1 npm publish --tag next --registry https://my.registry.com
ci-publish
自动发布都有内部registry
了, 自动发布流程就也可以安排上了. 由于npm
只支持真实用户账号, 所以我们也可以同样直接使用账号的token
来进行发布
在本地登录npm login --registry https://my.registry.com
后, 可以在~/.npmrc
文件下看到属于my.registry.com
的token
, 将此token
设置到ci
环境变量中, 通过脚本在发布时注入即可完成权限验证(此方法也适用于正式npm
站点)
1 2 npm config set //my.registry.com/:_authToken ${NPM_TOKEN} npm publish --verbose --registry https://my.registry.com