portainer的二次开发

一、准备环境
依赖: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"
不要大些第一个字母
语句最后不要使用点 (.) 

Leave a Comment

电子邮件地址不会被公开。 必填项已用*标注