cnpmcore icon indicating copy to clipboard operation
cnpmcore copied to clipboard

【求助】老版本cnpm迁移cnpmcore的方案

Open baxtergu opened this issue 3 years ago • 15 comments

目前我司内部有正在运行的老版本 cnpm 私有部署服务(syncModel: 'exist',磁盘已用10+TB),已经运行了多年并且有很多私有scope的包了,有几个迁移相关的问题想要寻求一下解答:

  • 现在只想迁移私有 scope 的包,请问这种方案有没有支持?(下载量数据、dist-tag 等需要保留)
  • 已有的用户是否能够迁移

baxtergu avatar Oct 21 '22 07:10 baxtergu

我们已经看到你的反馈,如果是功能缺陷,可以提供一下重现该问题的方式;如果是新功能需求,我们会尽快加入讨论。同时我们非常期待你可以加入我们的贡献者行列,让项目可以长期可持续发展。

github-actions[bot] avatar Oct 21 '22 07:10 github-actions[bot]

@baxtergu 你重新搭建一个新的 cnpmcore,去老的 registry 同步。

fengmk2 avatar Oct 22 '22 01:10 fengmk2

@baxtergu 你重新搭建一个新的 cnpmcore,去老的 registry 同步。

请问有办法只同步私有scope下的所有包吗?

baxtergu avatar Oct 25 '22 00:10 baxtergu

@baxtergu

cnpmcore 现在支持配置多个同步源,可以根据 scope 来进行同步。

需要升级一下 cnpmjs.org 到 3.0.0-rc.63,添加了一个 _changes 接口

然后配置一下 cnpmcore 的 registry 和 scope 配置,registry 中定义一下 changesStream 的地址和类型,关联一下对应 scope。

存量包可以通过脚本导入,同步时,添加 registryName 参数即可。

elrrrrrrr avatar Oct 25 '22 01:10 elrrrrrrr

然后配置一下 cnpmcore 的 registry 和 scope 配置,registry 中定义一下 changesStream 的地址和类型,关联一下对应 scope。

感谢回复,我们的私仓可能改过cnpm代码了(比较久远,版本已经对应不上社区版了),升级估计是行不通了。

假设不升级私有cnpm registry服务,直接通过 cnpmcore 做指定 scope 的同步这个方案可行吗?

baxtergu avatar Oct 25 '22 06:10 baxtergu

假设不升级私有cnpm registry服务,直接通过 cnpmcore 做指定 scope 的同步这个方案可行吗?

也可以的,只是同步期间会有一些增量部分会不一致。 创建完 registry 之后,可以尝试导出一下需要迁移的包列表,然后通过脚本来触发,参数中添加一下 registryName 即可 https://github.com/cnpm/cnpmcore/blob/sync-all-packages/test/SyncAllPackages.ts

elrrrrrrr avatar Oct 26 '22 02:10 elrrrrrrr

假设不升级私有cnpm registry服务,直接通过 cnpmcore 做指定 scope 的同步这个方案可行吗?

也可以的,只是同步期间会有一些增量部分会不一致。 创建完 registry 之后,可以尝试导出一下需要迁移的包列表,然后通过脚本来触发,参数中添加一下 registryName 即可 https://github.com/cnpm/cnpmcore/blob/sync-all-packages/test/SyncAllPackages.ts

大概了解了,我会尝试使用上面的这个方案来同步。感谢支持

baxtergu avatar Oct 31 '22 07:10 baxtergu

楼上的几位,实践完后写篇迁移指南文章 PR 回来?

atian25 avatar Nov 01 '22 00:11 atian25

@baxtergu 或许可以试试 我这边的迁移脚本~

以 verdaccio 迁移到 cnpm 为例,其实任意私服的流程都一样的


#!/bin/bash
LOG_COLOR_RED="\033[31m"
LOG_COLOR_GREEN="\033[32m"
LOG_COLOR_YELLOW="\033[33m"

# ######################## todo: read 交互输入 ########################
# ### VERDACCIO_MODULES
# ### VERDACCIO_REGISTRY
# ### CNPMCORE_MODULES
# ### CNPMCORE_REGISTRY
# ### PACK_NAME
# todo: 目前单次迁移1个(含包内多版本),并需要人工确认; 整个目录迁移可能会有意外情况
#
# 保存的模块压缩包 后缀
ZIP_EXT=tgz
# verdaccio 包保存的地址
VERDACCIO_MODULES=/verdaccio/storage
VERDACCIO_REGISTRY=https://vdc-yours.domain
# cnpmcore 包保存的地址
CNPMCORE_MODULES=/data/npmStore/nfs/packages
CNPMCORE_REGISTRY=https://npm-core.yours.domain
# 迁移的包的名称; 如:@vdc/example_3 ;里面可能有 example_3-1.x.x.tgz 多个包
PACK_NAME=@vdc/example_3

ENV=prod

if [ x$1 != x ];then
  echo "ENV参数 "$1
  if [ $1 != 'dev' ];then
    ENV=$1
  fi
fi

if [ x$2 != x ];then
  PACK_NAME=$2
  echo "PACK_NAME 参数 "$2
fi

NPM_R="npm --registry $CNPMCORE_REGISTRY"
NPM_VDC="npm --registry $VERDACCIO_REGISTRY"

# 错误输出 封装 建议入参用 双引号 "
myEcho() {
  # 0 绿色-正常提示, 1 黄色-警告, 2 红色错误
  case $2 in
      1) level=$LOG_COLOR_YELLOW'[WARN' ;;
      2) level=$LOG_COLOR_RED'[ERR' ;;
      *) level=$LOG_COLOR_GREEN'[INFO' ;;
  esac
  time_str=`date "+%F %T"`
  echo $ECHOE "[${time_str}] $level] [$ENV]: $1 \033[0m"
}

# 包名中有驼峰的转中划线
function camel_to_hyphen(){
  echo $1 | sed -E 's/([A-Z])/-\1/g' | sed -E 's/^-//g' | tr 'A-Z' 'a-z'
}

# 解压所有版本并发布
function find_mod_vers(){
  CHILD_DIR=$(ls -lht $1/$2 | grep -e "^.*\.$3" | wc -l)

  myEcho "[注意观察]: 包名: $2 ; 路径: $1 ; 版本个数: $CHILD_DIR " 1
  local idx=1
  for item in `ls $1/$2`
  do
    cd $1/$2
    if [ -n "$(echo "$item" | grep -e "^.*\.$3" )" ];then
      local f_name=${item##*/}
      local t_name=${f_name%.*}
      # step 1. 解压 压缩包 对应的版本
      myEcho "解压 处理第($idx)个: $item; 文件与版本号: $t_name ;"
      rm -rf $t_name && mkdir -p $t_name
      # 一些windows开发环境的同学发包后,会有权限问题(解压与进入目录)
      sudo chmod a+rwx ./*
      sudo tar -zxPf $item -C $t_name
      sudo chmod -R a+rwx $t_name && ls -lht ./$t_name/package
      # verdaccio 的目录规范
      cd $t_name/package
      # step 2. 一些标准化的统一替换
      # 2.1 如果包名为驼峰camel(大写)的转中划线 hyphen case
      local hyphen_nm=$(camel_to_hyphen $2)
      sed -in-place s%$2%$hyphen_nm% ./package.json
      # 2.2 如果配置了 publishConfig registry 地址则需要替换
      local pkg=$(cat package.json)
      if [ -n "$(echo "$pkg" | grep publishConfig)" ];then
        myEcho "替换registry地址(兼容域名-http-https以及IP)."
        sed -in-place s%$VERDACCIO_REGISTRY%$CNPMCORE_REGISTRY%g ./package.json
        # ----- 【注】: verdaccio经历了多次迁移,需要再次替换老地址 -----
        # sed -in-place s%http://yours.domain:4873%$CNPMCORE_REGISTRY%g ./package.json
        # sed -in-place s%http://yours.domain%$CNPMCORE_REGISTRY%g ./package.json
      fi
      # step 3. 构建流程忽略,注释 prepublishOnly 因为仓库的包已经发布(编译过)
      sed -in-place s/prepublish/ign_prepublish/g ./package.json
      # 注释作者,后续机器人ci发布,含 authors
      sed -in-place s/author/ign_author/g ./package.json
      # step 4. 发布 对应版本
      myEcho "准备执行发布命令:$NPM_R publish" 1
      sleep 1
      $NPM_R publish

      ((idx++))
    fi
  done
}


######################## 具体步骤 start ########################
myEcho "=====> 开始前请自行登录 $NPM_VDC whoami " 1
myEcho "=====> 另请确认 $PACK_NAME 是否有权限发布到$CNPMCORE_REGISTRY" 2
sleep 1
# step 1. 进入要迁移的包,确认有多少版本,以及各版本压缩包大小
myEcho "处理 $PACK_NAME 预备 ..."
sleep 1
# 提前确认本脚本的在服务器上的执行权限
sudo chmod a+rwx $VERDACCIO_MODULES/$PACK_NAME
cd $VERDACCIO_MODULES/$PACK_NAME
myEcho "当前包内版本大小..."
du -h -d 1 * | grep -e "^.*\.$ZIP_EXT"
# step 2. 遍历该包的所有版本,并逐个进行迁移
find_mod_vers $VERDACCIO_MODULES $PACK_NAME $ZIP_EXT
myEcho "done ... 请查看发布结果的日志"
######################## 具体步骤 end ########################


# sh verdaccio2cnpmcore.sh prod @vdc/example_4

迁移日志长这样:

highsea avatar Dec 30 '22 02:12 highsea

更新一下经过一段时间尝试后归纳出的方案,请各位帮忙看下有没有问题:

搭建一个新的 cnpmcore 的 registry 服务后:

  • 创建一个 admin 用户并获取 token
  • 通过接口方式(POST {newCnpmCore}/-/registry)创建一名为个 old-cnpm 的 registry,host: 老仓库,type npm。
  • cnpmcore 开启 download 数据同步,并
    • download 数据同步上游设置为老仓库,
    • registry 源设置为 npmmirror
    • 并使用 exist 模式
  • 获取所有老仓库中的私有包名,并在 https://github.com/cnpm/cnpmcore/blob/sync-all-packages/test/SyncAllPackages.ts 脚本的基础上,指定上面创建的 registryName 并批量执行:
const url = `http://{newCnpmCoreRegistry}/-/package/${fullname}/syncs`;

const request = axios.create({
  headers: {
    "Authorization": `Bearer ${token}`,
    "Content-Type": "application/json"
  }
});

async function createTask(url) {
  const result = await request(url, {
    method: 'PUT',
    data: JSON.stringify({
      registryName: "old-cnpm",
      syncDownloadData: true,
      skipDependencies: true,
      // 这两个 force 参数有什么具体的作用?
      force: true,
      forceSyncHistory: true
    })
  });
  return result;
}

基于上面的步骤能够满足:

  • 老仓库的私有包从老仓库同步
  • Exist 模式触发的非私有包从 default registry(也就是 npmmirror 同步)

不太清楚是否还有更好的解决方案?或者说,有没有通过配置而不是手动触发接口来绑定私有包名与同步 registry 关系的方案?(通过 API 创建需要创建管理员用户、创建非 default registry,手动批量触发 sync 接口三个步骤)。似乎在:https://github.com/cnpm/cnpmcore/pull/292 有提到。

私有包全部同步完成 + 公共包同步数量上来之后,要做的操作:

  • 把老仓库同步过来的私有用户密码批量重新生成(salt + password -> newIntegrity)
  • users 表中的用户 is_private 标记 0-> 1
  • 私有包在 packages 表中的 is_private 标记 0->1
  • 是否还要在切换前在通过接口批量触发一次私有包的全量数据同步保证不丢download数据?

上面的方案是在试用、读 Issue PR、以及读部分源代码的基础上得出的,能麻烦评估下是否合理,是否有一些边界情况需要注意的。

目前使用发现的问题

  • 同步过来的 user 和 login 创建的用户的 passwordSalt 长度不一致
  • 如果不开启 changeStream 配置的话,似乎 registry 中 default 的registry 不会自动生成,这是预期内的吗?
  • npm login 时似乎默认是用 weblogin,通过修改 AuthAdapter 的 getUrl 返回为一个 404 地址的方式可以降级为直接在命令行中登录。(webAuthn false的情况下)

baxtergu avatar Apr 21 '23 06:04 baxtergu

@baxtergu

同步过来的 user 和 login 创建的用户的 passwordSalt 长度不一致

同步回来的 user 会默认创建一个 password,实际没有消费场景

如果不开启 changeStream 配置的话,似乎 registry 中 default 的registry 不会自动生成,这是预期内的吗?

不开启 changesStream 的话,registry 配置应该没有消费场景。如果 registry 开启发包的话,默认会生成 self 配置

npm login 时似乎默认是用 weblogin,通过修改 AuthAdapter 的 getUrl 返回为一个 404 地址的方式可以降级为直接在命令行中登录。(webAuthn false的情况下)

在之前的版本中,默认会返回 404 地址,也许添加一个配置来决定是否开启 webauth 登录功能更加合理

elrrrrrrr avatar May 04 '23 13:05 elrrrrrrr

@baxtergu

同步过来的 user 和 login 创建的用户的 passwordSalt 长度不一致

同步回来的 user 会默认创建一个 password,实际没有消费场景

从老版本的cnpm同步的用户,有什么办法可以在用户无感知的情况下,把他们的密码改回去吗?在不知道用户密码的情况下

shilucus avatar May 05 '23 08:05 shilucus

@baxtergu

同步过来的 user 和 login 创建的用户的 passwordSalt 长度不一致

同步回来的 user 会默认创建一个 password,实际没有消费场景

从老版本的cnpm同步的用户,有什么办法可以在用户无感知的情况下,把他们的密码改回去吗?在不知道用户密码的情况下

库里保存加密后的结果,应该反解不出原始密码

baxtergu avatar May 05 '23 10:05 baxtergu

我们最近进行了迁移。把一些经验简单说一下。 迁移最好使用源码部署迁移。迁移完成之后,可以使用npm包的方式来跑。这样就能同步官方的修改。 我这块的需求是,之前是使用的全部同步的模式,使用云存储已经占了10几个T的空间。这次需要把私有包先都同步到新的cnpmcore,然后在测试安装速度,如果安装速度没什么特别大的波动。就使用不同步的模式。公共包直接使用registry.npmmirror.com。所以需要先把私有包同步,如果有需求在切换模式同步公共包。 第一步 1、clone项目代码,然后执行sql, 把表结构搭建好。能启动项目 启动项目这块最好吧@elastic/elasticsearch和sqlite3的版本锁一下,要不不容易启动。 服务端启动npm run tsc npm run start 需要先编译成js在启动。可以新建一个config.prod.ts把正式和测试区别一下 2、如果需要web页面,在同步之前需要把es搭建起来。因为同步的时候需要做搜索的插入,或者自己把adapter这块改成sql的搜索。直接搭建比较简单。或者公司有集群直接用就行了。需要8版本的es。这个按文档来就行了。如果部署多点。需要配置成0.0.0.0 echo 'network.host: 0.0.0.0' >> config/elasticsearch.yml让多个机器可以访问。 第二步 正式同步之前可以在测试环境跑一下。我这直接按正式同步的说。 1、老版的源,在周末的时候把,发包功能关闭。同步也停掉。 2、然后把sourceRegistry设置成老源。 3、然后通过接口获取老源的所有私有包,通过手动触发同步的方式来同步私有包 这块几处修改, 第一是老源的修改,把之前返回页面的地方,改成返回json image

第二处修改是把同步过来的包进行一个过滤,只保留私有包,因为会同步私有包的依赖。这块不知道是不是可以通过配置来解决。我直接加了一个过滤的代码。 在PackageSearchService.ts里面加了。我们这私有包都是有scopes,接直接简单过滤了一下。加了这个同步的包就只有私有包了。之后需要去掉。 image 第三处修改,同步过来的用户账户密码在新的里面不能直接登录。需要在同步的时候重置一下,这也是为什么上面要只保留私有包。 UserService.ts里面 image 我全给重置成123456,因为之前大部分的人密码就是123456,所以就改成123456,如果使用123456,需要把密码校验从8改成6。因为我没接公司的登录系统,如果接入了也就不用重置了。 我还加了两个接口,用来重置单个或者全部的人。在同步的时候也可以用。单个之后别人忘记密码,可以用来修改。全部的删掉就行。

   // WebauthController.ts
   @HTTPMethod({
    path: '/-/v1/login/resetUserPassword/:username',
    method: HTTPMethodEnum.GET,
  })
  async resetUserPassword(@Context() ctx: EggContext, @HTTPParam() username: string) {
    try {
      ctx.tValidate(UserRule, {
        name: username,
        password: '123456',
      });
    } catch (err) {
      const message = err.message;
      return { ok: false, message: `Unauthorized, ${message}` };
    }
    await this.userService.resetUserPassword(username, '123456');
    return { ok: true }
  }

  @HTTPMethod({
    path: '/-/v1/login/resetAll/:token',
    method: HTTPMethodEnum.GET,
  })
  async resetAll(@HTTPParam() token: string) {
    if (token !== 'asdlkajsdjasdj') {
      return { ok: false, message: 'Unauthorized' };
    }
    await this.userService.resetAll();
    return { ok: true }
  }
  // UserService.ts

  async resetUserPassword(name: string, password: string) {
    const passwordSalt = crypto.randomBytes(30).toString('hex');
    const plain = `${passwordSalt}${password}`;
    const passwordIntegrity = integrity(plain);
    const result = await this.userRepository.resetUserPassword(name, passwordSalt, passwordIntegrity);
    return result
  }

  async resetAll() {
    const userList = await this.userRepository.findAll();
    userList.forEach(async(user) => {
      user.isPrivate = true;
      const passwordSalt = crypto.randomBytes(30).toString('hex');
      const plain = `${passwordSalt}${123456}`;
      const passwordIntegrity = integrity(plain);
      user.passwordSalt = passwordSalt;
      user.passwordIntegrity = passwordIntegrity;
      await this.userRepository.saveUser(user);
    })
  }
  
  // UserRepository.ts
  
  async resetUserPassword(name: string, passwordSalt: string, passwordIntegrity: string) {
    const model = await this.User.findOne({ name });
    if (!model) return;
    model.passwordSalt = passwordSalt,
    model.passwordIntegrity = passwordIntegrity,
    model.isPrivate = true
    await model.save();
  }

第三步 就是写一个脚本来同步所有的接口。就是循环创建就行了。可以跑两遍。第一次把时间调的长一点。后面调端一些。

import urllib from 'urllib';


async function createTask(url) {
  const result = await urllib.request(url, {
    method: 'PUT',
    timeout: 10000,
    dataType: 'json',
  });
  return result;
}


async function main() {
  const result = await urllib.request('https://cnpm.xxxxxx.com/privates', {
    method: 'GET',
    timeout: 10000,
    dataType: 'json',
  });
  const packages = []
  Object.keys(result.data.scopes).forEach(key => {
    result.data.scopes[key].forEach(item => {
      packages.push(item.name)
    })
  })
  console.log(packages.length)
  const total = packages.length
  let index = 0
  var interval = setInterval(() => {
    if (index >= total) {
      clearInterval(interval)
      return
    }
    console.log(index, packages[index])
    run(packages[index])
    index++
  }, 10000)
}
async function run(fullname) {
  try {
    const url = `http://xxxxxx/-/package/${fullname}/syncs`;
    console.log('url', url)
    const result = await createTask(url);
    const data = result.data;
    if (data && data.id) {
      const logUrl = `${url}/${data.id}/log`;
      console.log(result.status, logUrl)
    } else {
      console.log(result.status, fullname)
    }
  } catch (err) {
    console.error('[%s/%s] %s, error: %s', fullname, err.message);
  }
}

main();

第四步 切换新的源,进行测试。然后把npmweb部署一下,REGISTRY换成自己的,把广告去掉一下。npm run build 然后 pm2 start npm --name cnpmweb -- run start 我是在服务器直接部署的,可以把配置文件里面的output: 'export', 去掉。如果需要同步全部的包。把之前的修改去掉。或者直接换成npm包的形成启动

chenjinxinlove avatar Jan 13 '24 07:01 chenjinxinlove

如果正式库同步出问题。可以通过下面的代码,把数据库重置一下

PackageRepository.ts
import { HistoryTask as HistoryTaskModel } from './model/HistoryTask';
import { PackageVersionFile as PackageVersionFileModel } from './model/PackageVersionFile';


  @Inject()
  private readonly HistoryTask: typeof HistoryTaskModel;

  @Inject()
  private readonly PackageVersionFile: typeof PackageVersionFileModel;
  

 async removeAll() {
    const res = await this.Package.find()
    // 删除所有
    res.forEach(async (model) => {
      await model.remove()
    })
    const res2 = await this.Dist.find()
    // 删除所有
    res2.forEach(async (model) => {
      await model.remove()
    })
    const res3 = await this.PackageVersionManifest.find()
    // 删除所有
    res3.forEach(async (model) => {
      await model.remove()
    })
    const res4 = await this.PackageTag.find()
    // 删除所有
    res4.forEach(async (model) => {
      await model.remove()
    })
    const res5 = await this.Maintainer.find()
    // 删除所有
    res5.forEach(async (model) => {
      await model.remove()
    })
    const res6 = await this.PackageVersion.find()
    // 删除所有
    res6.forEach(async (model) => {
      await model.remove()
    })
    const res7 = await this.User.find()
    // 删除所有
    res7.forEach(async (model) => {
      await model.remove()
    })
    // 删除所有
    const res8 = await this.HistoryTask.find()
    res8.forEach(async (model) => {
      await model.remove()
    })
    // 删除所有
    const res9 = await this.PackageVersionFile.find()
    res9.forEach(async (model) => {
      await model.remove()
    })
  }

chenjinxinlove avatar Jan 13 '24 07:01 chenjinxinlove