一、准备环境
依赖:Docker, Node.js >= 0.8.4 和 npm
[root@dev_08 ~]# curl --silent --location https://rpm.nodesource.com/setup_7.x | sudo bash -
[root@dev_08 ~]# yum install -y nodejs
[root@dev_08 ~]# npm install -g grunt-cli
二、构建
1、checkout
[root@dev_08 ~]# cd /opt
先 fork 一个 portainer的分支,然后 clone 到本地, 然后在 branch 上开发,例如:
[root@dev_08 opt]# git clone https://github.com/opera443399/portainer.git
[root@dev_08 opt]# cd portainer
[root@dev_08 portainer]# git checkout -b feat-add-container-console-on-task-details
Switched to a new branch 'feat-add-container-console-on-task-details'
[root@dev_08 portainer]# git branch
develop
* feat-add-container-console-on-task-details
2、使用 npm 安装依赖包
[root@dev_08 portainer]# npm install -g bower && npm install
3、根目录没有这个目录: bower_components 的话则执行
[root@dev_08 portainer]# bower install --allow-root
4、针对 centos 执行
[root@dev_08 portainer]# ln -s /usr/bin/sha1sum /usr/bin/shasum
5、构建 app
[root@dev_08 portainer]# grunt build
如果遇到这样的错误:
Building portainer for linux-amd64
/go/src/github.com/portainer/portainer/crypto/crypto.go:4:2: cannot find package "golang.org/x/crypto/bcrypt" in any of:
/usr/local/go/src/golang.org/x/crypto/bcrypt (from $GOROOT)
/go/src/golang.org/x/crypto/bcrypt (from $GOPATH)
/go/src/github.com/portainer/portainer/http/handler/websocket.go:21:2: cannot find package "golang.org/x/net/websocket" in any of:
/usr/local/go/src/golang.org/x/net/websocket (from $GOROOT)
/go/src/golang.org/x/net/websocket (from $GOPATH)
mv: cannot stat ‘api/cmd/portainer/portainer-linux-amd64’: No such file or directory
Warning: Command failed: build/build_in_container.sh linux amd64
mv: cannot stat ‘api/cmd/portainer/portainer-linux-amd64’: No such file or directory
Use --force to continue.
Aborted due to warnings.
那是因为网络可达性问题,国内访问 golang.org 异常。
[root@dev_08 portainer]# host golang.org
golang.org is an alias for golang-consa.l.google.com.
golang-consa.l.google.com has address 216.239.37.1
导致这2个依赖下载失败:
golang.org/x/crypto/bcrypt
golang.org/x/net/websocket
解决方法:
[root@dev_08 portainer]# go get github.com/golang/crypto/tree/master/bcrypt
[root@dev_08 portainer]# go get github.com/golang/net/tree/master/websocket
[root@dev_08 portainer]# cd $GOPATH/src
[root@dev_08 src]# mkdir golang.org/x -p
[root@dev_08 src]# mv github.com/golang/* golang.org/x/
然后再切换到源码目录,调整构建脚本:
[root@dev_08 src]# cd /opt/portainer
[root@dev_08 portainer]# vim build/build_in_container.sh
挂载本地的 $GOPATH/src/golang.org 到容器路径:/go/src/golang.org
docker run --rm -tv $(pwd)/api:/src -e BUILD_GOOS="$1" -e BUILD_GOARCH="$2" portainer/golang-builder:cross-platform /src/cmd/portainer
调整为:
docker run --rm -tv $(pwd)/api:/src -v $GOPATH/src/golang.org:/go/src/golang.org -e BUILD_GOOS="$1" -e BUILD_GOARCH="$2" portainer/golang-builder:cross-platform /src/cmd/portainer
最后重新构建一次:
[root@dev_08 portainer]# grunt build
(略)
Cleaning "dist/js/angular.37dfac18.js"...OK
Cleaning "dist/js/portainer.cab56db9.js"...OK
Cleaning "dist/js/vendor.4edc9b0f.js"...OK
Cleaning "dist/css/portainer.e7f7fdaa.css"...OK
Done, without errors.
看到上述输出,表示符合预期。
6、运行(可以自动重启)
[root@dev_08 portainer]# grunt run-dev
访问 UI 地址: http://localhost:9000
7、不要忘记 lint 代码
[root@dev_08 portainer]# grunt lint
8、release(通常我们使用 linux-amd64 这个平台,具体过程请参考脚本 build.sh)
[root@dev_08 portainer]# grunt "release:linux:amd64"
(略)
Done, without errors.
[root@dev_08 portainer]# ls dist/
css fonts ico images index.html js portainer-linux-amd64
[root@dev_08 portainer]# mv dist/portainer-linux-amd64 dist/portainer
9、打包成镜像
[root@dev_08 portainer]# docker build -t 'opera443399/portainer:dev' -f build/linux/Dockerfile .
10、测试上述镜像
[root@dev_08 portainer]# mkdir -p /data/portainer_dev
[root@dev_08 portainer]# docker run -d -p 9001:9000 -v /var/run/docker.sock:/var/run/docker.sock -v /data/portainer_dev:/data --name portainer_dev opera443399/portainer:dev
[root@dev_08 portainer]# docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cbd986df765b opera443399/portainer:dev "/portainer" 8 seconds ago Up 7 seconds 0.0.0.0:9001->9000/tcp portainer_dev
首次使用时将初始化一个管理员账户(本例使用 httpie 来提交)
[root@dev_08 portainer]# http POST :9001/api/users/admin/init Username="admin" Password="Develop"
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=utf-8
Date: Tue, 10 Oct 2017 08:18:19 GMT
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
访问页面:your_dev_ip:9001
验证功能:符合预期
清理:
[root@dev_08 portainer]# docker rm -f portainer_dev
三、开发需求示例
可结合 github 的搜索功能来查找关键字。
1、需求:在 'Container list' 页面,设定初始化时,默认不显示所有容器(即默认不勾选 'Show all containers' 这个复选框)
注1:从 1.14.1 版本开始使用 cookie 来记录是否显示所有的状态(Persist the status of the show all containers filter: #1198),其实完全可以不更改代码,去掉 checkbox 的选择后,下次登录还是 unchecked 的状态,本例仅作为修改代码的一个 howto 来展示。
注2:从 1.14.1 版本开始,新增了针对资源的限制(Add the ability to manage CPU/MEM limits & reservations for Swarm services: #516),不妨一试。
https://github.com/portainer/portainer/releases
为了达到我们的小目标,需要调整 filter_containerShowAll 在初始化时的默认值为 false 即可。
[root@dev_08 portainer]# diff -u /tmp/localStorage.js app/services/localStorage.js
--- /tmp/localStorage.js 2017-09-26 15:11:41.062167776 +0800
+++ app/services/localStorage.js 2017-09-26 15:21:11.604992708 +0800
@@ -50,7 +50,7 @@
getFilterContainerShowAll: function() {
var filter = localStorageService.cookie.get('filter_containerShowAll');
if (filter === null) {
- filter = true;
+ filter = false;
}
return filter;
}
2、需求:在 'Service details' 页面的 'Tasks' 标签页中增加一个过滤器
当前操作列出了所有的 tasks
而我有时候只需要查看处于 running 状态的 tasks 即可,因而需要一个过滤器,改动代码如下:
[root@dev_08 portainer]# diff -u /tmp/tasks.html app/components/service/includes/tasks.html
--- /tmp/tasks.html 2017-09-26 18:00:45.893169748 +0800
+++ app/components/service/includes/tasks.html 2017-09-26 18:22:10.188208664 +0800
@@ -12,6 +12,11 @@
</select>
</div>
</rd-widget-header>
+ <rd-widget-taskbar classes="col-lg-12">
+ <div class="pull-right">
+ <input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
+ </div>
+ </rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
@@ -48,7 +53,7 @@
</tr>
</thead>
<tbody>
- <tr dir-paginate="task in (filteredTasks = ( tasks | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
+ <tr dir-paginate="task in (filteredTasks = ( tasks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td>
具体请参考:
https://github.com/portainer/portainer/pull/1242
3、需求:在 'Service details > Tasks > Task details' 页面增加一个 Container Console 按钮
当前操作要找到 service 下所有容器的 Console 并打开,则要执行下述步骤:
1 个 service 对应 N 个 tasks(containers) (运行在 M nodes 中)
-> 打开 'Service Details' 页面,过滤处于 running 状态的 tasks 并注意对应的 node 名称
-> 选择 node N1 并切换到 endpoint N1
-> 在 N1 的 'Container List' 页面上通过过滤器找到 container C1
-> 在 'Container Details' 页面打开 Console
为了简化操作,实现咱们的小目标,改动代码如下:
-> 在 'Service details > tasks' 页面,打开 'Task details' 页面时跳转到新的标签页
-> 在 'Task details' 页面,新增 container console 按钮,新增一个提示框,提示后续步骤遇到错误时如何处理
-> 点击 Console 按钮打开 'Container console' 页面时,跳转到新的标签页
如果该 Container 不在当前 endpoint 中运行,则有一个错误通知:
'Failure: Docker container identifier not found'
此时,我们要做的就是一个 endpoint 切换的操作
-> 当我们切换 endpoint 时,不是跳转页面到 dashboard 页面,而仅仅使用一个消息通知,然后等待页面刷新后,得到的页面即我们想要的。
-> 如果我们切换到 swarm worker node 则后续需要一个切换回 swarm manager node 的操作
-> 小结:用户体验有点糟糕,但能工作。
[root@dev_08 portainer]# diff -u /tmp/tasks.html app/components/service/includes/tasks.html
--- /tmp/tasks.html 2017-10-12 11:55:23.247181711 +0800
+++ app/components/service/includes/tasks.html 2017-10-12 10:12:52.653767466 +0800
@@ -54,7 +54,7 @@
</thead>
<tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
- <td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td>
+ <td><a ui-sref="task({ id: task.Id })" class="monospaced" target="_blank">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td>
<td>{{ task.NodeId | tasknodename: nodes }}</td>
[root@dev_08 portainer]# diff -u /tmp/task.html app/components/task/task.html
--- /tmp/task.html 2017-10-11 14:22:29.782178036 +0800
+++ app/components/task/task.html 2017-10-12 10:13:28.367767064 +0800
@@ -44,7 +44,12 @@
</tr>
<tr ng-if="task.Status.ContainerStatus.ContainerID">
<td>Container ID</td>
- <td>{{ task.Status.ContainerStatus.ContainerID }}</td>
+ <td title="Notify 'Failure: Docker container identifier not found' -> Try 'Switch endpoint to the node where the container is running'">
+ {{ task.Status.ContainerStatus.ContainerID }}
+ <a class="btn btn-outline-secondary" target="_blank" type="button" ui-sref="console({id: task.Status.ContainerStatus.ContainerID})">
+ <i class="fa fa-terminal space-right" aria-hidden="true"></i>Console
+ </a>
+ </td>
</tr>
</tbody>
</table>
[root@dev_08 portainer]# diff -u /tmp/sidebarController.js app/components/sidebar/sidebarController.js
--- /tmp/sidebarController.js 2017-10-12 11:57:10.063180508 +0800
+++ app/components/sidebar/sidebarController.js 2017-10-12 09:37:32.314244435 +0800
@@ -14,7 +14,7 @@
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
StateManager.updateEndpointState(true)
.then(function success() {
- $state.go('dashboard');
+ Notifications.success('switch endpoint to: ', endpoint.Name);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
具体请参考:
https://github.com/portainer/portainer/pull/1271
四、源码分析示例
1、在 UI 界面中是如何请求 docker api 来完成 container list 之类的请求的?
分析过程:
1)例如,在 Container list 页面,有如下请求:
/api/endpoints/1/docker/containers/json?all=0
列出了处于 running 状态的容器,我们去找找相关的代码。
2)找找 js 代码
[root@dev_08 portainer]# find ./app -type f -name '*.js' -exec grep -l 'docker\/containers' {} \;
./app/rest/docker/container.js
./app/rest/docker/containerLogs.js
[root@dev_08 portainer]#
[root@dev_08 portainer]# vim app/rest/docker/container.js
angular.module('portainer.rest')
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
name: '@name',
endpointId: EndpointProvider.endpointID
},
3)找找 go 代码
[root@dev_08 portainer]# find ./api -type f -name '*.go' -exec grep -l '\/api\/endpoints' {} \;
./api/http/handler/handler.go
[root@dev_08 portainer]#
[root@dev_08 portainer]# vim api/http/handler/handler.go
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/endpoints"): // 该请求的 URL 是 /api/endpoints 开头的
if strings.Contains(r.URL.Path, "/docker") { // 且URL 包含 /docker
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) // 去掉 /api/endpoints 变成 /1/docker/containers/json?all=0 然后交给 DockerHandler 来处理
[root@dev_08 portainer]# vim api/http/handler/docker.go
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) // 去掉 /1/docker 变成 /containers/json?all=0 (这就是最终请求 docker api 的路径)
通过上述代理行为,最终变成请求类似这样的 docker api (具体的请求是走的本地 socket 还是 tcp 的行为尚未分析)
curl -s \
--unix-socket /var/run/docker.sock \
http:/containers/json?all=0
或者:
curl -s \
http://endpoint_ip:port/containers/json?all=0
五、FAQ
1、为何不能统一管理 swarm mode 集群下的资源?
状态:
我理解,这个UI的开发理念是一个简单通用的docker UI
目前的方式是:每个 node 都是一个 endpoint,要切换 endpoint 来执行 docker API 指令获取该 node 上的容器等信息。
未来计划增加 swarm mode 集群管理的功能。
具体的讨论,还请跟踪下这个 issue 相关的内容:
[FEATURE REQUEST] Be able to use all the Portainer built-in functionalities in all the containers running in a swarm cluster #461
https://github.com/portainer/portainer/issues/461
2、如何提交 github PR 来为本项目共享代码?
请先阅读:https://github.com/portainer/portainer/blob/develop/CONTRIBUTING.md
贡献代码的指引
1)通用
先看看有没有相关的 PR/issues 已经存在,如果没有则进入下一步
打开一个 issue 讨论你要带来的一些变化(也可以在已经存在 的 issue 上讨论),包括功能合理性和技术可行性等方面,在解决方案上讨论出一个解决,一致同意后,再去开发【这一步很重要,不能跨过】
在一个特定主题的分支上开发,不要在 master/develop 分支开发!
新建分支时,有一些命名规则,例如:
(type)(issue number)-text-desc
举例:
你在提交一个 bugfix 来解决 issue #361 则分支可以命名为: fix361-template-selection.
参考这里的讨论:
https://github.com/portainer/portainer/issues/1271#issuecomment-336033282
2)为已经打开的 issues 贡献代码
想做却不知道如何开始?
有一些 issues 的标签是 exp/ 开头的,意味着可以供大家去开发,但有难度的区分:
beginner: 针对不太熟系该项目代码的开发者
intermediate: 针对该项目代码有一定理解,或对 AngularJS or Golang 有使用经验的开发者
advanced: 针对该项目代码有深入理解的开发者
可以使用 Github filters 来过滤出 issues:
beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner
intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate
advanced labeled issues: https://github.com/portainer/portainer/labels/exp%2Fadvanced
3)代码 Linting
检查代码使用 grunt lint 然后再提交 PR
4)提交的信息格式
<type>(<scope>): <subject>
不要超过 100 字符,整洁的 commit log 示例:
#271 feat(containers): add exposed ports in the containers view
#270 fix(templates): fix a display issue in the templates view
#269 style(dashboard): update dashboard with new layout
有如下几类 type
feat: A new feature
fix: A bug fix
docs: Documentation only changes
style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
refactor: A code change that neither fixes a bug or adds a feature
test: Adding missing tests
chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
Scope
关于 scope :表示
提交的代码是改动了哪一块的内容,例如:networks, containers, images 可以使用 area 标签来关联 issue (例如:标签 area/containers 使用 containers 作为a scope)
关于 subject :表示
简明的描述本次提交做了那些改变
使用祈使句和现在时: "change" not "changed" nor "changes"
不要大些第一个字母
语句最后不要使用点 (.)