Harbor迁移升级(从v1到v2)
势在必行的计划,前期越周全越好。
一,升级原因
1,安全漏洞
Harbor 官方仓库公布了 5 个漏洞,其中包括 2 个官方定级为严重的漏洞(CVE-2019-19025、CVE-2019-19023),2个高危级别漏洞(CVE-2019-19029、CVE-2019-19026),1个中等级别漏洞(CVE-2019-3990)。
- CVE-2019-19025:缺少 CSRF 保护漏洞,Harbor Web界面未实现针对跨站点请求伪造(CSRF)的保护机制。通过把经过身份验证的用户吸引到事先准备好的第三方网站,可导致第三方代表经过身份验证的用户或管理员在平台上执行任意操作。
- CVE-2019-19023:特权提升漏洞,该漏洞使普通用户可以通过API调用来修改特定用户的电子邮件地址,从而获得管理员帐户特权。漏洞源于Harbor API没有对修改电子邮件地址的API请求进行适当的权限限制。
- CVE-2019-19029:通过用户组进行SQL注入,具有项目管理功能的用户可以利用SQL注入来从底层数据库读取机密信息或进行权限提升。
- CVE-2019-19026:通过项目quotas进行SQL注入,Harbor API的quotas部分存在一个SQL注入漏洞。经过身份验证的管理员可以通过GET参数发送特制的SQL有效负载,从而从数据库中提取敏感信息。
- CVE-2019-3990:用户枚举漏洞,该漏洞存在于 “/users” api 中,这个功能应该仅限于管理员使用,可是该限制可被绕过,非管理员用户(例如通过自我注册创建的用户)可以通过向 /api/users/search发送 GET请求来列出所有用户名和用户ID、确认与用户名关联的电子邮件地址等。
2,功能升级
Harbor 的 OCI Artifact 功能,可以用来存储、分发和管理机器学习的模型文件。
二,升级流程
harbor-migrate.png三,升级步骤
先按v1版的harbor配置,建一个v2版的harbor(挂载大存储,ldap用户认证等)。
1,导出v1版本的项目和镜像列表
运行harbor_v1_images_export.py脚本,从V1版本harbor中,提取所有的项目列表和镜像文件列表。
为支持多次运行此脚本,会导入之前运行结果,以缩短再次运行的时间。
其中,项目列表为pro.csv文件,镜像列表为repo_v1.csv文件
"""
用于将老版本的harbor中的Project和repo镜像提取出来,保存到文件中。
admin
2020-11-24
"""
import requests
# 常量定义
harbor_domain_v1 = 'harbor.demo.cn'
username = 'admin'
password = 'xxxx'
pro_file = 'pro.csv'
repo_file = 'repo_v1.csv'
# V1版本,请求API使用session。
class RequestClient:
def __init__(self, login_url, username, password):
self.login_url = login_url
self.username = username
self.password = password
self.session = requests.Session()
self.login()
def login(self):
self.session.post(self.login_url,
params={'principal': self.username,
'password': self.password})
print('login', self.session)
# 将Harbor常用操作包装成一个class
class HarborReposV1:
def __init__(self, harbor_domain, username, password, schema='http'):
self.schema = schema
self.harbor_domain = harbor_domain
self.harbor_url = self.schema + '://' + harbor_domain
self.harbor_login_url = self.harbor_url + '/login'
self.harbor_api_url = self.harbor_url + '/api'
self.harbor_pro_url = self.harbor_api_url + '/projects'
self.harbor_repos_url = self.harbor_api_url + '/repositories'
self.username = username
self.password = password
# self.client和self.pros_obj在初始化时就生成好,使用起来更流畅
self.client = RequestClient(self.harbor_login_url,
self.username,
self.password)
self.pros_obj = self.__fetch_pros_obj()
def __fetch_pros_obj(self):
return self.client.session.get(self.harbor_pro_url).json()
# 获取所有的project id
def fetch_pros_id(self):
pros_id = list()
for i in self.pros_obj:
pros_id.append(i['project_id'])
return pros_id
# 根据project id获取project名称及public属性
def fetch_pro_name(self, pro_id):
for i in self.pros_obj:
if i['project_id'] == pro_id:
pro_name = i['name']
pro_public = i['metadata']['public']
return pro_name, pro_public
# 根据project id获取此project下所有镜像名称
def fetch_repos_name(self, pro_id):
repos_name = list()
repos_res = self.client.session.get(self.harbor_repos_url,
params={'project_id': pro_id})
for repo in repos_res.json():
repos_name.append(repo['name'])
return repos_name
# 根据镜像名称,获取此镜像的所有tag
def fetch_repos(self, repo_name):
repos = list()
harbor_tag_url = self.harbor_repos_url + '/' + repo_name + '/tags'
repos_res = self.client.session.get(harbor_tag_url)
for tag in repos_res.json():
full_repo_name = '{}/{}:{}'.format(self.harbor_domain, repo_name, tag['name'])
repos.append(full_repo_name)
return repos
# 将文件内的所有行读取成列表
def read_file_list(file_name):
with open(file_name, 'r') as f_r:
return [line.strip() for line in f_r]
# 将指定内容追加到指定文件
def insert_file(file_name, content):
# 对于python3, 第三个参数,指定编码方式必须要加encoding=“utf-8”
with open(file_name, 'a', encoding='utf-8') as f_w:
print(content)
f_w.write('{}\r\n'.format(content))
print("保存{}成功".format(content))
if __name__ == '__main__':
# 初始化harbor连接数据
res_v1 = HarborReposV1(harbor_domain_v1, username, password)
# 如果文件中已存在项目和镜像,则先提取到列表里,避免重复插入,这样脚本就可以多次运行迁移
try:
pro_list = read_file_list(pro_file)
repo_list = read_file_list(repo_file)
except FileNotFoundError: # 文件不能找到的异常处理
pro_list = []
repo_list = []
print("首次运行,还没有文件,继续处理。。.")
# 获取所有project id
for pro_id in res_v1.fetch_pros_id():
pro_name, is_public = res_v1.fetch_pro_name(pro_id)
line_str = '{},{}'.format(pro_name, is_public)
# 只将新的项目加入文件
if line_str not in pro_list:
insert_file(pro_file, line_str)
else:
print('project{}已存在于文件{}中'.format(line_str, pro_file))
# 获取到所有镜像名称
repos_name = res_v1.fetch_repos_name(pro_id=pro_id)
for repo_name in repos_name:
# 获取到镜像的所有tag
repos = res_v1.fetch_repos(repo_name=repo_name)
for full_repo_name in repos:
# 只将新的镜像tag加入文件
if full_repo_name not in repo_list:
insert_file(repo_file, full_repo_name)
else:
print('镜像tag{}已存在于文件{}中'.format(full_repo_name, repo_file))
2,在v2版中导入项目列表
运行harbor_v2_projects_create.py脚本,将pro.csv文件中的项目列表导入新版harbor中,其中,保留了每个项目的public属性(true or false)。
"""
将从将仓库里获取到的所有project,在新仓库中重建好,带public属性的
admin
2020-11-24
"""
import requests
from requests.auth import HTTPBasicAuth
import json
pro_file = 'pro.csv'
harbor_domain_v2 = 'harbor-test.demo.cn:8086'
v2_username = 'admin'
v2_password = 'xxxxx'
# Harbor V2版本的class
class HarborReposV2:
def __init__(self, harbor_domain, username, password, schema='http'):
self.schema = schema
self.harbor_domain = harbor_domain
self.harbor_url = self.schema + '://' + harbor_domain
# 新版harbor 2版本的api地址
self.harbor_api_url = self.harbor_url + '/api/v2.0'
self.harbor_pro_url = self.harbor_api_url + '/projects'
self.username = username
self.password = password
# 使用HTTPBasicAuth认证,这也是避免那些CSRF Token Invalid的最佳办法
# 是从harbor API里看认证方式获得的启发。
self.auth = HTTPBasicAuth(self.username, self.password)
# 好像只要三个要素,就可以新建一个project了。细节待完善。
def create_pros(self, pro_name, is_public):
pro_obj = dict()
pro_obj['project_name'] = pro_name
pro_obj["metadata"] = dict()
pro_obj["metadata"]["public"] = is_public
# pro_obj["metadata"]["enable_content_trust"] = i["enable_content_trust"]
# pro_obj["metadata"]["prevent_vul"] = i["prevent_vulnerable_images_from_running"]
# pro_obj["metadata"]["severity"] = i["prevent_vulnerable_images_from_running_severity"]
# pro_obj["metadata"]["auto_scan"] = i["automatically_scan_images_on_push"]
headers = {"content-type": "application/json"}
res = requests.post(self.harbor_pro_url,
auth=self.auth,
headers=headers,
data=json.dumps(pro_obj))
if res.status_code == 409:
print("\033[32m 项目 %s 已经存在!\033[0m" % pro_name)
return True
elif res.status_code == 201:
# print(res.status_code)
print("\033[33m 创建项目%s成功!\033[0m" % pro_name)
return True
else:
print("\033[35m 创建项目%s失败!\033[0m" % pro_name)
return False
# 从旧仓库导出来的projects列表,读取出来
def read_file_list(file_name):
with open(file_name, 'r') as f_r:
return [line.strip() for line in f_r]
if __name__ == '__main__':
# 初始化
res_v2 = HarborReposV2(harbor_domain_v2, v2_username, v2_password)
pro_list = read_file_list(pro_file)
for item in pro_list:
pro_name, is_public = item.split(',')
res_v2.create_pros(pro_name, is_public)
3,在v2版中导入镜像列表
运行harbor_v2_images_import.py脚本,将repo_v1.csv文件中的镜像列表导入新版harbor中,同时,生成repo_v2.csv作为校验文件,以支持多次运行此脚本。
为了让中间的迁移机器不至于容量爆掉,在每迁移完一个镜像的所有tag之后,会删除此镜像的所有文件(每个镜像的所有tag,会共用基础层,如果导入一个镜像就删除一个镜像,效率会很慢,且重复传输严重,想你一个有300个tag的镜像)。
"""
用于保存到文件中镜像迁移到新的harbor镜像仓库当中。
admin
2020-11-24
"""
import subprocess
# 定义常量
repo_file = 'repo_v1.csv'
new_repo_file = 'repo_v2.csv'
harbor_domain_v1 = 'harbor.demo.cn'
v1_username = 'admin'
v1_password = 'xxxx'
harbor_domain_v2 = 'harbor-test.demo.cn:8086'
v2_username = 'admin'
v2_password = 'xxxx'
repo_list = list()
repo_dict = dict()
new_repo_list = list()
def read_file_list(file_name):
with open(file_name, 'r') as f_r:
return [line.strip() for line in f_r]
def insert_file(file_name, content):
# 对于python3, 第三个参数,指定编码方式必须要加encoding=“utf-8”
with open(file_name, 'a', encoding='utf-8') as f_w:
print(content)
f_w.write('{}\r\n'.format(content))
print("保存{}成功".format(content))
# 从旧版的harbor中pull镜像,tag更名之后,push到新仓库,记得先登陆
def migrate_repos(v1_repo_name, v2_repo_name):
cmd_list = []
old_repo_login = "docker login {} -u {} -p {}".format(harbor_domain_v1, v1_username, v1_password)
pull_old_repo = "docker pull " + v1_repo_name
tag_repo = "docker tag " + v1_repo_name + " " + v2_repo_name
new_repo_login = "docker login {} -u {} -p {}".format(harbor_domain_v2, v2_username, v2_password)
push_new_repo = "docker push " + v2_repo_name
cmd_list.append(old_repo_login)
cmd_list.append(pull_old_repo)
cmd_list.append(tag_repo)
cmd_list.append(new_repo_login)
cmd_list.append(push_new_repo)
ret_sum = 0
for cmd in cmd_list:
print("\033[34m Current command: %s\033[0m" % cmd)
ret = subprocess.call(cmd, shell=True)
ret_sum += ret
if ret_sum == 0:
print("\033[32m migrate %s success!\033[0m" % v2_repo_name)
insert_file(new_repo_file, v2_repo_name)
return True
else:
print("\033[33m migrate %s faild!\033[0m" % v2_repo_name)
return False
# 当一个镜像的所有tag迁移完成之后,清除此镜像所有tag,挪出空间,不然会爆掉
def delete_local_repos(repo, tags):
for tag in tags:
repo_tag = '{}:{}'.format(repo, tag)
cmd = 'docker rmi {}'.format(repo_tag)
if subprocess.call(cmd, shell=True) == 0:
print("\033[32m 删除 {} 成功!\033[0m".format(repo_tag))
else:
print("\033[32m 删除 {} 失败,继续执行!\033[0m".format(repo_tag))
if __name__ == '__main__':
try:
new_repo_list = read_file_list(new_repo_file)
except FileNotFoundError:
print('首次导入。还没有文件。')
# 这里的骚操作,是为了能让同一个镜像的不同tag,作同一批次的pull和push,
# 操作完之后,才作docker image rmi的操作,肯定会显著缩短时间,
# 因为同一个镜像的不同tag,很多层是相同的
# 字典的键为镜像名,值为tag列表
for item in read_file_list(repo_file):
repo, tag = item.split(':')
if repo not in repo_dict:
repo_dict[repo] = [tag]
else:
repo_dict[repo].append(tag)
# 遍历这个字典,作迁移
for item in repo_dict.items():
repo, tags = item
# 新的仓库的镜像地址,需要整合新仓库的地址及旧仓库的项目repo名称
repo_replace = repo.split('/')
repo_replace[0] = harbor_domain_v2
v2_repo = '/'.join(repo_replace)
# demo小剂量测试
if 'nginx-ingress-controller' in repo:
for tag in tags:
v1_repo_name = '{}:{}'.format(repo, tag)
v2_repo_name = '{}:{}'.format(v2_repo, tag)
# print(v1_repo_name)
# print(v2_repo_name)
# 已导入过的,忽略,减少时间
if v2_repo_name in new_repo_list:
print('{}已导入新harbor仓库'.format(v2_repo_name))
continue
# 真正的导出导入操作
if not migrate_repos(v1_repo_name, v2_repo_name):
print('导入失败')
break
else:
print('导入{}成功'.format(v2_repo_name))
# 这里使用for...else...配合continue和break,可以直接跳出两个for循环外面
else:
print('{}导入完成,清除此镜像的所有tag'.format(repo))
# 一个repo导入完成,清除新旧仓库的所有tag。
delete_local_repos(repo, tags)
delete_local_repos(v2_repo, tags)
continue
break
4,DNS切换
DNS切换,将指到v1版harbor的域名,指向v1版的harbor。
5,更新V2版配置
新版harbor更改配置,提供与域名一致的服务。
Harbor.yml
截屏2020-11-25下午1.29.07.png
重启harbor,使配置生效
docker-comppose down
./prepare
docker-compose up -d
(同时,v1版harbor更改为另外的域名或ip,不急马下线,待v2版稳定后下线,有个别镜像,还可以手工导入)
6,测试验证
在k8s环境,或是docker环境下,测试是否已平滑升级完成。
四,此种升级方案的优势和注意要点
在标准推荐的harbor升级方案中,从1.5到2.1,会涉及数据库的转换(从mysql转postgresql)。而我公司安装的harbor,是用的docker-compose方案,全docker部署,无形中增加了升级难度。
在我们设计的这个升级方案中,如果在DNS切换后,测试失败,是可以作回滚的,只要DNS切回即可。
另外,它也支持断点持续升级。也就是在空间和时间许可的情况下,分多个批次,来将V1版的镜像迁移到V2版中。而在DNS切换的这个维护时间窗口内,只需要迁移极少的镜像,花极少的时间来作最后的升级。而无须在短短一天之内,迁移上T的数据。
参考URL:
http://blog.nsfocus.net/cve-2019-19025-cve-2019-19023-cve-2019-19029-cve-2019-19026-cve-2019-3990/
https://www.cnblogs.com/breezey/p/10615242.html