Harbor 源码分析 之 Core 组件

chenxull · · 1580 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

core 组件

此文件夹是 harbor 的核心组件。在根目录下有main.gorouter.go二个文件。前者用来初始化整个后端项目,后者是对请求的路由处理。在分析一个操作的执行流程时,可以从路由文件入手,一步一步分析。

harbor 中的 controller设计,在common/api文件夹中实现了一个BaseAPI具体实现如下

type BaseAPI struct {
	beego.Controller
}
复制代码

这个BaseAPI实现了ControllerInterface接口中的部分方法。在给 harbor 添加新的功能时,此功能对外暴露出来的 API首先需要隐式的继承BaseController结构体,在BaseController结构体中继承了BaseAPI

projectAPI为例,它的具体实现如下

type ProjectAPI struct {
	BaseController
	project *models.Project
}
复制代码

上述 API 中继承的BaseController实现如下,除了继承了 BaseAPI 之外,还实现了确保安全的上下文,以及项目管理器。

// BaseController ...
type BaseController struct {
	api.BaseAPI
	// SecurityCtx is the security context used to authN &authZ
	SecurityCtx security.Context
	// ProjectMgr is the project manager which abstracts the operations
	// related to projects
	ProjectMgr promgr.ProjectManager
}
复制代码

通过上述继承实现,用户在添加新的功能时,需要按照这样的实现逻辑添加新的控制器。

router.go

注册 harbor 支持的所有的路由,每个路由分为三个部分,具体实现如下所示:

beego.Router("/c/login", &controllers.CommonController{}, "post:Login")
复制代码

第一个参数是浏览器访问的地址,第二个参数是MVC 中的 C。Controller是用来处理不同URL的控制器,不同的路径有不同的控制器。在 harbor 中大致有一下二种控制器:

  • controllers.CommonController
  • api 中的不同操作的控制器(细分种类很多)

每个控制器都实现了一下的接口

type ControllerInterface interface {
	Init(ct *context.Context, controllerName, actionName string, app interface{})
	Prepare()
	Get()
	Post()
	Delete()
	Put()
	Head()
	Patch()
	Options()
	Finish()
	Render() error
	XSRFToken() string
	CheckXSRFCookie() bool
	HandlerFunc(fn string) bool
	URLMapping()
}
复制代码

实现了上述接口定义的函数的结构体,通过路由根据url执行相应的controller的原则,会依次执行下列函数

Init()      初始化
Prepare()   执行之前的初始化,每个继承的子类可以来实现该函数
method()    根据不同的method执行不同的函数:GET、POST、PUT、HEAD等,子类来实现这些函数,如果没实现,那么默认都是403
Render()    可选,根据全局变量AutoRender来判断是否执行
Finish()    执行完之后执行的操作,每个继承的子类可以来实现该函数
复制代码

每个控制器的 Prepare 函数中都实现了认证功能,具体实现有差别,认证这一逻辑会具体介绍。

api

大部分功能的路由功能都在这里实现。

/api/users

post

添加新的用户到数据库中,用户的数据结构定义如下:

type User struct {
	UserID   int    `orm:"pk;auto;column(user_id)" json:"user_id"`
	Username string `orm:"column(username)" json:"username"`
	Email    string `orm:"column(email)" json:"email"`
	Password string `orm:"column(password)" json:"password"`
	Realname string `orm:"column(realname)" json:"realname"`
	Comment  string `orm:"column(comment)" json:"comment"`
	Deleted  bool   `orm:"column(deleted)" json:"deleted"`
	Rolename string `orm:"-" json:"role_name"`
	// if this field is named as "RoleID", beego orm can not map role_id
	// to it.
	Role int `orm:"-" json:"role_id"`
	//	RoleList     []Role `json:"role_list"`
	HasAdminRole bool         `orm:"column(sysadmin_flag)" json:"has_admin_role"`
	ResetUUID    string       `orm:"column(reset_uuid)" json:"reset_uuid"`
	Salt         string       `orm:"column(salt)" json:"-"`
	CreationTime time.Time    `orm:"column(creation_time);auto_now_add" json:"creation_time"`
	UpdateTime   time.Time    `orm:"column(update_time);auto_now" json:"update_time"`
	GroupList    []*UserGroup `orm:"-" json:"-"`
}

复制代码

post方法会对发送过来的请求进行一系列验证,只有当这些验证都通过时。前端界面上的注册按钮才生校,点击注册按钮后。表单中的信息会被发送到后端服务器上来,服务器在验证一遍用户的信息是否符合规范,当通过所有检查后,调用dao.registry(user)进行注册,返回用户 ID。

/api/users/:user_id/password

更改密码服务 **Put()**方法,先检查验证模式是否允许更改密码。

controllers

此文件夹实现了用户登录,登出,检查用户或邮箱是否存储,重置邮箱,重置密码等功能。

proxy

拦截器:拦截器是指对浏览器到服务器的请求数据或者服务器到浏览器的返回数据做一些更改,或将请求的数据做一些增强。

定义了对于发送过来请求的处理链路,以及拦截器的实现。通过ServeHTTP将实现的功能暴露出去。

这个拦截器会拦截pull manifest请求,对这个请求进行改增。将 image 和tag/sha256 digest 作为返回值的第二和第三个参数。

interceptors.go

urlHandler结构体的 ServeHTTP方法主要用来对原有的request进行处理,使其附带上镜像信息

ServeHTTP首先对请求进行正则处理,提取出flag, repository, reference信息。使用repositorytokenUsername信息调用NewRepositoryClientForUI()函数创建一个 repository client仅用来访问内部的 registry,这个client增加了 "User-Agent" header到请求中,同时加上token 服务所需要的信息。

readonlyHandler结构体的 ServeHTTP方法主要用来对request进行限制,禁止请求的访问。

listReposHandler结构体的 ServeHTTP方法主要用来返回的请求上添加镜像仓库的信息。包含了仓库的内容及其长度信息,放在 Content-Length 字段中。

rw.Header().Set(http.CanonicalHeaderKey("Content-Length"), strconv.Itoa(clen))
rw.Write(respJSON)
复制代码

**contentTrustHandler**结构体的 ServeHTTP方法只有在Notary服务开启时才生效

**vulnerableHandler**结构体的 ServeHTTP方法只有在clair服务开启后才能生效。

最后二个等到需要实时再来仔细看。

auth

实现了用户的登录认证,在 harbor 中用户登录认证的方式分为二种:

  • 数据库
  • LDAP 方式
  • uaa 认证

认证器的接口如下,用户可以很方便的实现自己的认证方式只要实现了下面接口提供的方法即可。

// AuthenticateHelper provides interface for user management in different auth modes.
type AuthenticateHelper interface {

	// Authenticate authenticate the user based on data in m.  Only when the error returned is an instance
	// of ErrAuth, it will be considered a bad credentials, other errors will be treated as server side error.
	Authenticate(m models.AuthModel) (*models.User, error)
	// OnBoardUser will check if a user exists in user table, if not insert the user and
	// put the id in the pointer of user model, if it does exist, fill in the user model based
	// on the data record of the user
	OnBoardUser(u *models.User) error
	// Create a group in harbor DB, if altGroupName is not empty, take the altGroupName as groupName in harbor DB.
	OnBoardGroup(g *models.UserGroup, altGroupName string) error
	// Get user information from account repository
	SearchUser(username string) (*models.User, error)
	// Search a group based on specific authentication
	SearchGroup(groupDN string) (*models.UserGroup, error)
	// Update user information after authenticate, such as OnBoard or sync info etc
	PostAuthenticate(u *models.User) error
}
复制代码

在 harbor 中可以给每个项目增加新的成员,新的组进行细粒度管理。其中的权限认证就是通过上述的几种方法实现的。(猜测)

CommonController控制器的Login方法就调用了auth中登录的实现。根据登录模式的不同选择不同的验证方式,传入的参数为用户名和密码。

默认的验证方式为数据库验证,登录验证的逻辑如下:

  1. 检查认证方式
  2. 设置锁,避免短时间内有多次登录请求互相抢占
  3. 完成上述检查后,根据配置认证方式的不同调用对应的认证器

主要分析数据库认证

数据库认证实现的比较简单,直接调用dao层中的LoginByDb函数,以 orm 的方式直接构建 sql 语句查询是否有对应的用户在数据库中,查询成功后用户实例对象。数据库认证方式的PostAuthenticate方法没有执行任何操作。没有问题后,认证完成。使用此用户信息创建 session,用户登录成功。

promgr

这里实现了对 harbor 中项目的管理。抽象出来一个项目管理的常规操作并将其定义为接口类型,ProjectManager也实现了PMSDriver接口的方法。所以当用户在增加新的ProjectManager的时,只要实现了下面的方法也会实现PMSDriver接口。

type ProjectManager interface {
	Get(projectIDOrName interface{}) (*models.Project, error)
	Create(*models.Project) (int64, error)
	Delete(projectIDOrName interface{}) error
	Update(projectIDOrName interface{}, project *models.Project) error
	List(query *models.ProjectQueryParam) (*models.ProjectQueryResult, error)
	IsPublic(projectIDOrName interface{}) (bool, error)
	Exists(projectIDOrName interface{}) (bool, error)
	// get all public project
	GetPublic() ([]*models.Project, error)
	// if the project manager uses a metadata manager, return it, otherwise return nil
	GetMetadataManager() metamgr.ProjectMetadataManager
}
复制代码

PMSDriver的驱动共有 2 中分别为admirallocal,这二者的区别有待研究。

ProjectManager中存储的数据如下所示:

  {
    "project_id": 1,
    "owner_id": 1,
    "name": "library",
    "creation_time": "2019-02-15T12:58:31.485307Z",
    "update_time": "2019-02-15T12:58:31.485307Z",
    "deleted": false,
    "owner_name": "",
    "togglable": true,
    "current_user_role_id": 1,
    "repo_count": 1,
    "metadata": {
      "auto_scan": "true",
      "enable_content_trust": "false",
      "prevent_vul": "false",
      "public": "true",
      "severity": "low"
    }
  },
复制代码

config

main.go中调用了config.Init()函数,因为配置文件的处理是在adminserver组件中完成的,所以在 config.go中通过创建一个adminserver的client ,使用 http 通信的方式访问adminserver的 URL 来获取配置文件的信息。

为了确保通信的安全,adminserver的 client 在创建时会使用CORE_SECRET参数进行加密。创建好的 client 会被赋值给一个Manager结构体,这是用来管理配置文件的,具体结构如下:

// Manager manages configurations
type Manager struct {
	client client.Client
	Cache  bool
	cache  cache.Cache
	key    string
}
复制代码

配置文件是直接存储在内存上。

	if enableCache {
		m.Cache = true
		m.cache = cache.NewMemoryCache()
		m.key = "cfg"
	}
复制代码

在完成Manager的创建之后,会调用 client 的GetCfgs方法从adminserver请求配置信息,并将配置信息存储在内存中。jobservice与其他组件进行通信的密码。

完成上述的操作后,初始化ProjectManager。当 harbor 开启admiral是需要对 http 服务使用 x509 进行加密生成AdmiralClient,在创建PSM驱动时需要将AdmiralClient作为参数传入进行,在后续的通信过程中使用。同时海域要传入 token 服务的阅读器,具体实现如下。

driver = admiral.NewDriver(AdmiralClient, AdmiralEndpoint(), TokenReader)
复制代码

在没有开启admiral的模式下,直接使用本地的数据库创建 项目管理器(ProjectManager)的驱动。最后使用上述的驱动创建一个全局项目管理器单例,用来对 harbor 的项目进行管理。

还有一些功能是 LDAP 的实现,这里不做介绍。

filter

用来对一些请求进行过滤,只有符合要求的请求才被允许访问 harbor。这里的过滤主要分为三种分别为:

  • SecurityFilter
  • ReadonlyFilter:在GC 的过程汇总开启
  • MediaTypeFilter:只有这几种类型的请求才能访问"application/json", "multipart/form-data", "application/octet-stream"

前二种过滤器对所有的请求都进行过滤,最后一种只对/api/*类型的请求过滤。

SecurityFilter

遍历之前定义的四中修改器,对 req 对用每种修改器的Modify(req)方法。

secretReqCtxModifier对发送的请求增加了二种信息:

  • harbor_security_context:获取 req header中的secret,然后通过这个secret和secret store 创建NewSecurityContext
  • harbor_project_manager:

basicAuthReqCtxModifier 获取请求中的用户名和密码,然后使用账户信息进行登录验证,如果请求中发来的用户信息和数据库中一致。就可以对请求进行修改,给请求增加用户和 pm 信息。具体如下

securCtx := local.NewSecurityContext(user, pm)
	setSecurCtxAndPM(ctx.Request, securCtx, pm)
复制代码

sessionReqCtxModifier 对前端发送来的请求进行修改,通过req中的session信息得到用户的详细信息。然后将用户的信息和GlobalProjectMgr信息存储到req的 context 中,具体实现如下

	securCtx := local.NewSecurityContext(&user, pm)

	setSecurCtxAndPM(ctx.Request, securCtx, pm)
复制代码

unauthorizedReqCtxModifier 对于没有授权的请求,用户信息为 nil,在修改req时没有用户信息有pm

	securCtx = local.NewSecurityContext(nil, pm)
 	setSecurCtxAndPM(ctx.Request, securCtx, pm)

复制代码

ReadonlyFilter

开启 readonly 过滤后,当请求中有删除操作或重新打 label 操作时,请求都会被禁止。

GetSecurityContext

此函数在 BaseControllerPrepare函数中被调用,用来获取请求中的security context并返回。在发送到后端的数据,都是通过security context来进行传递的,前端的数据通过上述过滤器被修改为security context

根据请求中的关键字SecurCtxKey=harbor_security_context来获取security context

GetProjectManager

获取请求中的project manager。根据请求中的关键字PmKey=harbor_project_manager来获取。

日志的实现

  • 如何实现让日志服务成为单独一个容器,记录来自不同的容器的日志信息

主要需要考虑的就是在每个记录日志的地方,留意日志的输出是设置到哪里的。

除了 jobservice文件夹,其他的组件使用的都是src/common/utils/log提供的日志服务,比较的轻量级。

context 设计

common包中定义了context接口,抽象出操作的通用方法。判断前端发来的请求中,用户的身份信息是否有相对应的权限,都是在这个接口中定义的。

// Context abstracts the operations related with authN and authZ
type Context interface {
	// IsAuthenticated returns whether the context has been authenticated or not
	IsAuthenticated() bool
	// GetUsername returns the username of user related to the context
	GetUsername() string
	// IsSysAdmin returns whether the user is system admin
	IsSysAdmin() bool
	// IsSolutionUser returns whether the user is solution user
	IsSolutionUser() bool
	// HasReadPerm returns whether the user has read permission to the project
	HasReadPerm(projectIDOrName interface{}) bool
	// HasWritePerm returns whether the user has write permission to the project
	HasWritePerm(projectIDOrName interface{}) bool
	// HasAllPerm returns whether the user has all permissions to the project
	HasAllPerm(projectIDOrName interface{}) bool
	// Get current user's all project
	GetMyProjects() ([]*models.Project, error)
	// Get user's role in provided project
	GetProjectRoles(projectIDOrName interface{}) []int
}
复制代码

上述定义的context接口具体有三种实现分别为:admiral,local,secret。

重点分析 local 也就是数据库模式的实现。在local 模式中,定义了一个信息的结构体用来管理用户和项目。通过这个结构体将用户和项目关联在一个。

// SecurityContext implements security.Context interface based on database
type SecurityContext struct {
	user *models.User
	pm   promgr.ProjectManager
}
复制代码

IsAuthenticated

真正的验证过程是在core/filter中的security.go实现的,IsAuthenticated函数只是调用之前处理好的内容。在security.go中已经获取到来用户和 pm 信息,验证是只需检查用户是否为空即可。

func (s *SecurityContext) IsAuthenticated() bool {
	return s.user != nil
}
复制代码

GetProjectRoles

用来查找摸个项目用多少用户。先根据请求提供的用户名在数据库中查找多对应的用户信息,查找的 sql 语句如下:

sql := `select user_id, username, password, email, realname, comment, reset_uuid, salt,
		sysadmin_flag, creation_time, update_time
		from harbor_user u
		where deleted = false  and user_id = ? and username = ? and reset_uuid = ? and email = ?`
复制代码

查询到用户数据之后,开始查询项目,传入的参数projectIDOrNameSecurityContext调用ProjectManagerGet方法来根据projectIDOrName参数查询项目。这个 Get方式根据pmsDriver驱动的不同调用不同的实现,因为这里pmsDriver驱动模式是local,会直接在数据中进行查询,具体的项目查询实现如下:

// Get ...
func (d *driver) Get(projectIDOrName interface{}) (
	*models.Project, error) {
	id, name, err := utils.ParseProjectIDOrName(projectIDOrName)
	if err != nil {
		return nil, err
	}

	if id > 0 {
		return dao.GetProjectByID(id)
	}

	return dao.GetProjectByName(name)
}
复制代码

最后在根据上述查询到的用户名和项目名来查询此项目有多少用户。

最后返回给前端的 json 格式数据如下:

[
  {
    "id": 1,
    "project_id": 1,
    "entity_name": "admin",
    "role_name": "projectAdmin",
    "role_id": 1,
    "entity_id": 1,
    "entity_type": "u"
  },
  {
    "id": 2,
    "project_id": 1,
    "entity_name": "chenxu",
    "role_name": "developer",
    "role_id": 2,
    "entity_id": 3,
    "entity_type": "u"
  }
]
复制代码

展现出来的效果如下:

GetMyProjects

查询实现如下,具体来说是调用了ProjectManagerList方法,根据PSM实现的具体驱动调用对应的方法。这里驱动是local,获取总的镜像数量和每个镜像的详细信息。

result, err := s.pm.List(
		&models.ProjectQueryParam{
			Member: &models.MemberQuery{
				Name:      s.GetUsername(),
				GroupList: s.user.GroupList,
			},
		})
复制代码

前端接收到的json 数据格式如下:

[
  {
    "id": 1,
    "name": "library/centos",
    "project_id": 1,
    "description": "",
    "pull_count": 0,
    "star_count": 0,
    "tags_count": 1,
    "labels": [],
    "creation_time": "2019-05-10T04:21:34.499267Z",
    "update_time": "2019-05-10T04:21:34.499267Z"
  },
  {
    "id": 2,
    "name": "library/ubuntu",
    "project_id": 1,
    "description": "",
    "pull_count": 0,
    "star_count": 0,
    "tags_count": 1,
    "labels": [],
    "creation_time": "2019-05-10T04:22:17.538373Z",
    "update_time": "2019-05-10T04:22:17.538373Z"
  }
]
复制代码

展示界面如下

GetRolesByGroup

按组来查询用户,组的概念只有在 LADP 模式下才启用。

token 服务

registrynotary组件服务。这些组件需要 token 的验证服务才能正确的运行。

当用户pull/push镜像时都需要使用到 token 服务,token 服务其作用的基本流程如下:

下面具体讲解一下 harbor 中 token 服务是如何实现。当 harbor 的后端收到了GET "/service/token"路由请求时,会调用token.go中的 Get()方法。在 Get方法中主要完成一下几件事:

  • 获取请求中的 service即资源托管的服务地址service="registry.docker.io"
  • 根据Authorization Service 创建 token
  • 将获取的 token 以 json 格式发送回去

具体解释一下Authorization Service 是如何创建token 的。

生成 token

func (g generalCreator) Create(r *http.Request) (*models.Token, error)方法中主要的执行逻辑如下:

  • 获取请求中的 socpes。scope="repository:samalba/my-app:pull,push"
  • 获取请求中的securitycontextProjectManager
  • 根据socpes获取用户对镜像仓库的资源有何种访问权限:pull or push
  • 迭代资源操作列表(检查ProjectManager是否有对应的 project 同时判断用户对其有何种权限),并尝试使用与资源类型匹配的筛选器来筛选操作。执行的结果信息都保存在 ctx 中
  • 使用用户名,service,access 来生成 token 并返回。

这是生成 token的函数MakeToken(username, service string, access []*token.ResourceActions) (*models.Token, error)。生成 token 的核心函数如下:文件在src/core/service/token/authutils.go#143

func makeTokenCore(issuer, subject, audience string, expiration int,
	access []*token.ResourceActions, signingKey libtrust.PrivateKey) (t *token.Token, expiresIn int, issuedAt *time.Time, err error)
复制代码

从传入的数据中构造出镜像的基本信息:命名空间,镜像名,标签。具体数据结构如下:

type image struct {
	namespace string
	repo      string
	tag       string
}
复制代码

这里有二个过滤器分别为registryFilterrepositoryFilter。后者用来判断用户对于某个具体的项目拥有什么样的权限,rwmorRWorR

通知服务

主要用来获取扫描策略和复制策略的变化情况。每一个订阅事件都需要传入二个参数来初始化通知功能,这二个参数分别为topichandlertopic是需要通知事件的主题,handler是事件的处理方法。

其中handler定义为以下接口类型,便于用户的扩充。

type NotificationHandler interface {
	// Handle the event when it coming.
	// value might be optional, it depends on usages.
	Handle(value interface{}) error

	// IsStateful returns whether the handler is stateful or not.
	// If handler is stateful, it will not be triggerred in parallel.
	// Otherwise, the handler will be triggered concurrently if more
	// than one same handler are matched the topics.
	IsStateful() bool
}
复制代码

漏洞扫描

接下来主要分析一下漏洞扫描服务的通知机制。镜像扫描的topicscan_all_policyhandlerScanPolicyNotificationHandler其用来处理扫描策略的改变。这里策略有二种每天和无,每天选项可以设置一个具体的时间。

前端设置为每日上午 10 点进行扫描,发送给后端的数据 json格式为:

 "scan_all_policy": {
    "value": {
      "parameter": {
        "daily_time": 7200
      },
      "type": "daily"
    },
    "editable": true
  },
复制代码

在后端中会根据scan_all_policytype 字段执行对应的处理逻辑。首先取消之前的所有任务,避免阻塞。然后解析传入的时间构造调度cron,根据cron创建调度任务。

    // 先取消所有扫描任务
		if err := cancelScanAllJobs(); err != nil {
			return fmt.Errorf("Failed to cancel scan_all jobs, error: %v", err)
		}
		// 解析前端传入的时间
		h, m, s := common_utils.ParseOfftime(notification.DailyTime)
		cron := fmt.Sprintf("%d %d %d * * *", s, m, h)
		// 创建定时的调度任务
		if err := utils.ScheduleScanAllImages(cron); err != nil {
			return fmt.Errorf("Failed to schedule scan_all job, error: %v", err)
		}
复制代码

ScheduleScanAllImages中会任结构体,然后将任务通过 client 发送给jobservice组件。提交任务调用的是jobservice/api/v1/jobs接口,jobservice完成对应的处理后,返回任务的状态码。

/service/notifications/ 路由

adminjob

监听来自jobservice/service/notifications/jobs/adminjob/*路由请求。为 job 在数据库和jobservice之间建立映射关系。job 在数据库中以jobid的形式,在jobservice中以UUID的方式进行标识。

clair

用来监听 clair 的通知请求,监听的路由地址为/service/notifications/clair/。用来从 clair 获取漏洞数据并返回给前端展示。

jobs

用来处理 jobservice 的请求,监听的路由地址为/service/notifications/jobs/*。主要是用来更新扫描漏洞 job 的状态和更新镜像复制 job 的状态。

registry

监听 registry 的时间,监听路由为/service/notifications/。实现了对docker push 和 pull 。其中的Post函数用来实现所有功能。

当监听到新的/service/notifications/请求时,Post会首先提取出请求中Notification这是一个事件的包装器结构如下:

// Notification holds all events.
type Notification struct {
	Events []Event
}
复制代码

然后对传来的事件进行过滤,目前只支持二种事件。分别为:

  • 来自外部的 docker-client 的 pull or push 请求
  • 来自 jobservice 的 push 请求

完成事件过滤后,开始对事件进行加工提取,获取以下数据并持久化存储在数据库中:

dao.AddAccessLog(models.AccessLog{
				Username:  user,
				ProjectID: pro.ProjectID,
				RepoName:  repository,
				RepoTag:   tag,
				Operation: action,
				OpTime:    time.Now(),
			}
复制代码

接来是对具体的pushpull操作的实现。 push 当时push操作时,会首先检查数据库中是否有对应的repository。如果没有将操作的 push记录存储在数据库中,等待镜像的 manifest 文件准备好。

等上述准备工作都完成之后,事件通知器发布事件。具体实现如下:

err := notifier.Publish(topic.ReplicationEventTopicOnPush, rep_notification.OnPushNotification{
					Image: image,
				})
复制代码

上述操作会调用对应的处理器对镜像执行操作。可能是处理用户从上传来的镜像,也可能是镜像仓库之间的复制操作,具体要看调用的什么。当有镜像上传到存储库之后会检查是否开启了镜像自动扫描功能。

pull repository 中的pull计数器加 1。


有疑问加站长微信联系(非本文作者)

本文来自:掘金

感谢作者:chenxull

查看原文:Harbor 源码分析 之 Core 组件

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1580 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传