Elixir Phoenix如何用10分钟50行代码快速撸一个http透传通知服务

我们购买Github服务来托管源代码,用Jenkins来做持续集成,因此需要让github能通知Jenkins各种通知事件,触发持续集成构建。

公司网络环境为
Internet <------ 光猫 <------ 路由器/交换机 <------ 内网
光猫是是电信提供的100M光纤,自动拨号上网,网段192.168.1.x,不是路由器控制拨号上网; 路由器与内网为192.168.2.x。

Jenkins部署在内网上的CentOS主机,IP为192.168.2.231。

由于光猫是被电信阉割了的,许多功能(如端口转发)都被屏蔽了,而路由器在上设置端口转发也起不到作用。

即在CentOS上使用花生壳之类的,也无法让Github直接连通内网的Jenkins。

我们只能通过让Jenkins去定时轮询Github感知代码合并请求。这种方式有两个问题:

  1. 及时性不够;
  2. 代码合并事件无法感知;

为了弥补这个问题,我们的CI半手工触发,有些蛋疼。

最近正好在看Elixir 和 Phoenix, 两者分别与Ruby和RubyOnRails相对应。

elixir_logo.png

传送门:
http://elixir-lang.org/
http://www.phoenixframework.org/

有一句话说得很好,当你手里拿着一把锤子,但看到什么都是钉子。😂

因此,打算自己快速撸一个透传通知服务给github -> Jenkins 用。

方案比较简单,用Elixir Phoenix框架打一个应用 jenkins_hook_proxy,提供web 服务与websocket服务,运行在公网云主机上; 内网forwarder用websocket连接jenkins_hook_proxy,

  1. Jenkins_hook_proxy收到GitHub的通知时,将请求打包由websocket通知会内网,
  2. 内网forwarder收到websocket通知后,拆解请求后转给Jenkins
  3. Jenkins完成构建后,将构建结果通知 GitHub

GitHub ---http post--> [jenkins_hook_proxy] <---websocket---> [forwarder] ----http post----> Jenkins ----(CI构建结构) http post----> GitHub

由于Phoenix的channel为提供了非常方便且健壮的web前端浏览器js脚本,为了能直接利用它的js脚本,forwarder 就用nodejs编写。

整个过程下来,发现jenkins_hook_proxy 和 forwarder我们写的代码也就20句左右。

环境准备

Erlang 安装

CentOS上安装Erlang麻烦些,需要下载pre-built binary package安装
进入 https://www.erlang-solutions.com/resources/download.html 下载19.3的rpm包,然后用root用户安装 (sudo rpm -i eslxxx.rpm), 根据提示安装所需的依赖包(eg. wxBase, wxGTK等)。

Elixir

下载 elixir源码 https://github.com/elixir-lang/elixir/archive/v1.4.2.zip
解压后, make && sudo make install 安装。
安装完运行 iex --version 看看是否正常。

安装 Phoenix framework

http://www.phoenixframework.org/docs/installation

$ mix local.hex
由于安装的是1.3rc版,包为phx_new.ex。 phoenix_new.ez是1.2.x版本。
$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phx_new.ez

安装NodeJS

phoenix要求安装的有NodeJS用于编译assets。
$ sudo yum install node

创建运行在外网的项目 Jenkins_hook_proxy

$ mix phx.new jenkins_hook_proxy --database mysql

* creating jenkins_hook_proxy/config/config.exs
* creating jenkins_hook_proxy/config/dev.exs
* creating jenkins_hook_proxy/config/prod.exs
* creating jenkins_hook_proxy/config/prod.secret.exs
* creating jenkins_hook_proxy/config/test.exs
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/application.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/channels/user_socket.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/views/error_helpers.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/views/error_view.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/endpoint.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/router.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/web.ex
* creating jenkins_hook_proxy/mix.exs
* creating jenkins_hook_proxy/README.md
* creating jenkins_hook_proxy/test/support/channel_case.ex
* creating jenkins_hook_proxy/test/support/conn_case.ex
* creating jenkins_hook_proxy/test/test_helper.exs
* creating jenkins_hook_proxy/test/web/views/error_view_test.exs
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/gettext.ex
* creating jenkins_hook_proxy/priv/gettext/en/LC_MESSAGES/errors.po
* creating jenkins_hook_proxy/priv/gettext/errors.pot
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/repo.ex
* creating jenkins_hook_proxy/priv/repo/seeds.exs
* creating jenkins_hook_proxy/test/support/data_case.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/controllers/page_controller.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/templates/layout/app.html.eex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/templates/page/index.html.eex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/views/layout_view.ex
* creating jenkins_hook_proxy/lib/jenkins_hook_proxy/web/views/page_view.ex
* creating jenkins_hook_proxy/test/web/controllers/page_controller_test.exs
* creating jenkins_hook_proxy/test/web/views/layout_view_test.exs
* creating jenkins_hook_proxy/test/web/views/page_view_test.exs
* creating jenkins_hook_proxy/.gitignore
* creating jenkins_hook_proxy/assets/brunch-config.js
* creating jenkins_hook_proxy/assets/css/app.css
* creating jenkins_hook_proxy/assets/css/phoenix.css
* creating jenkins_hook_proxy/assets/js/app.js
* creating jenkins_hook_proxy/assets/js/socket.js
* creating jenkins_hook_proxy/assets/package.json
* creating jenkins_hook_proxy/assets/static/robots.txt
* creating jenkins_hook_proxy/assets/static/images/phoenix.png
* creating jenkins_hook_proxy/assets/static/favicon.ico

Fetch and install dependencies? [Yn] 

敲回车安装依赖包。

* running mix deps.get
* running mix deps.compile
* running cd assets && npm install && node node_modules/brunch/bin/brunch build

We are all set! Run your Phoenix application:

    $ cd jenkins_hook_proxy
    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

Before moving on, configure your database in config/dev.exs and run:

    $ mix ecto.create

创建Channel (websocket服务) JenkinsChannel.
JenkinsChannel仅作为websocket通道用于回传信息给内网,使用默认生成的代码就足够了。不是我们自己动手写的,不纳入行数计算 😜


$ cd Jenkins_hook_proxy
$ mix phx.gen.channel Jenkins
* creating lib/jenkins_hook_proxy/web/channels/jenkins_channel.ex
* creating test/web/channels/jenkins_channel_test.exs

Add the channel to your `lib/jenkins_hook_proxy/web/channels/user_socket.ex` handler, for example:

    channel "jenkins:lobby", JenkinsHookProxy.Web.JenkinsChannel


编辑 user_socket.ex , 把上面那句话加进去.
这算一行代码吧 😄

$ vi lib/jenkins_hook_proxy/web/channels/user_socket.ex

defmodule JenkinsHookProxy.Web.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "jenkins:lobby", JenkinsHookProxy.Web.JenkinsChannel

  ## Transports
  transport :websocket, Phoenix.Transports.WebSocket
  # transport :longpoll, Phoenix.Transports.LongPoll

  # Socket params are passed from the client and can
  # be used to verify and authenticate a user. After
  # verification, you can put default assigns into
  # the socket that will be set for all channels, ie
  #
  #     {:ok, assign(socket, :user_id, verified_user_id)}
  #
  # To deny connection, return `:error`.
  #
  # See `Phoenix.Token` documentation for examples in
  # performing token verification on connect.
  def connect(_params, socket) do
    {:ok, socket}
  end

  # Socket id's are topics that allow you to identify all sockets for a given user:
  #
  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
  #
  # Would allow you to broadcast a "disconnect" event and terminate
  # all active sockets and channels for a given user:
  #
  #     JenkinsHookProxy.Web.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
  #
  # Returning `nil` makes this socket anonymous.
  def id(_socket), do: nil
end

我们打算支持内网有多个jenkins的情况,同时,为了一点点安全考虑,每个jinkins我们分配一个随机字符串(如uuid)作为token, 至少一定程度上避免一些无效调用。用数据库来保存这个配置信息。

不需要HTML,因此直接用json创建整个脚手架(controller, model ...)

$ mix phx.gen.json Jenkins Callback callbacks host_id:string token:string callback_url:string

* creating lib/jenkins_hook_proxy/web/controllers/callback_controller.ex
* creating lib/jenkins_hook_proxy/web/views/callback_view.ex
* creating test/web/controllers/callback_controller_test.exs
* creating lib/jenkins_hook_proxy/web/views/changeset_view.ex
* creating lib/jenkins_hook_proxy/web/controllers/fallback_controller.ex
* creating lib/jenkins_hook_proxy/jenkins/callback.ex
* creating priv/repo/migrations/20170330015124_create_jenkins_callback.exs
* creating lib/jenkins_hook_proxy/jenkins/jenkins.ex
* creating test/jenkins_test.exs

Add the resource to your api scope in lib/jenkins_hook_proxy/web/router.ex:

    resources "/callbacks", CallbackController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate


创建数据库并执行迁移脚本创建callbacks表

$ mix ecto.create
$ mix ecto.migrate

编辑 lib/jenkins_hook_proxy/jenkins/jenkins.ex
在 get_callback 方法后面,添加get_callback_by_host_id

$ vi  lib/jenkins_hook_proxy/jenkins/jenkins.ex

  @doc """
  Gets a single callback by host_id.

  Raises `Ecto.NoResultsError` if the Callback does not exists.

  """
  def get_callback_by_host_id!(host_id), do: Repo.get_by!(Callback, host_id: host_id)

编辑callback_controller, 把现有的方法 index create show update delete 统统删掉,然后重写 create 方法,如下:

$ vi lib/jenkins_hook_proxy/web/controllers/callback_controller.ex

defmodule JenkinsHookProxy.Web.CallbackController do
  use JenkinsHookProxy.Web, :controller

  alias JenkinsHookProxy.Jenkins
  alias JenkinsHookProxy.Jenkins.Callback

  action_fallback JenkinsHookProxy.Web.FallbackController

  def create(conn, %{"host_id" => host_id, "token" => token} = params) do
    with %Callback{} = callback <- Jenkins.get_callback_by_host_id!(host_id) do
      case callback.token do
        ^token ->
          # token与数据库的token匹配
          payload = %{
            # 提取github请求头(x- 开头 和 user-agent), 用于内网转发
            github_headers: conn.req_headers
                            |> Enum.filter(fn({k, v}) -> to_string(k) |> String.starts_with?(["x-", "user-agent"]) end)
                            |> Enum.into(%{}),
            jenkins: %{
              # 内网Jenkins Hook地址
              "callback_url": callback.callback_url
            },
            # 原生请求头, 无实际用途,方便调试查看用
            raw_headers: conn.req_headers |> Enum.into(%{}),
            # github请求参数,剔除 host_id 与 token
            body: params |> Map.drop(["token", "host_id"])
          }
          # websocket广播,向通道 jenkins:lobby 回传 jenkins_msg 事件
          JenkinsHookProxy.Web.Endpoint.broadcast! "jenkins:lobby", "jenkins_msg", payload

          # 返回正常给github
          conn
          |> json(%{result: "ok"})

       _ ->
        # token无效,返回403给github
        conn
        |> put_status(:forbidden)
        |> json(%{error: "token not matched."})
      end
    end
  end
end

这些代码中,真正发挥作用的是

# websocket广播,向通道 jenkins:lobby 回传 jenkins_msg 事件
JenkinsHookProxy.Web.Endpoint.broadcast! "jenkins:lobby", "jenkins_msg", payload 

由于 Jenkins.get_callback_by_host_id!(host_id) 当数据没有相应host_id记录时,会抛出异常Ecto.NoResultsError。

action_fallback 机制是 Phoenix 1.3的一个非常不错的特性,controller中我们可以只写正常业务逻辑代码,错误处理移交给 fallback_controller 去集中处理,这一点对API类应用尤其实用。

修改 fallback_controller.ex ,加入 对 Ecto.NoResultsError 返回 404

$ vi lib/jenkins_hook_proxy/web/controllers/fallback_controller.ex


  def call(conn, {:error, %Ecto.NoResultsError{} = _error}) do
    conn
    |> put_status(:not_found)
    |> json(%{error: "Jenkins not found"})
  end

编辑路由,把 router.ex 中最后的 scope "api" 段打开并加入callback

$ vi lib/jenkins_hook_proxy/web/router.ex

  # Other scopes may use custom stacks.
  scope "/api", JenkinsHookProxy.Web do
    pipe_through :api
    post "/callbacks/:host_id/:token", CallbackController, :create
  end

可以运行 mix phx.routes 检查一下路由

$ mix phx.routes


    page_path  GET   /                               JenkinsHookProxy.Web.PageController :index
callback_path  POST  /api/callbacks/:host_id/:token  JenkinsHookProxy.Web.CallbackController :create

jenkins_hook_proxy的功能已经全部完成。

我们手动编辑添加的代码,去掉空行、注释行后,代码也就50行左右,如果按代码语句(为了阅读看起来舒服,一条代码语句会分成多行来写),都不足20条。

像这种,就是一条代码语句,却占了4行

        # token无效,返回403给github
        conn
        |> put_status(:forbidden)
        |> json(%{error: "token not matched."})

运行Jenkins_hook_proxy测试

用命令行交互方式启动,添加一条配置数据
host_id: test
token: abc123
callback_url: http://192.168.2.231:8020/github-webhook

JenkinsHookProxy.Jenkins.create_callback(%{host_id: "test", token: "abc123", callback_url: "http://192.168.2.231:8020/github-webhook"})

$ iex -S mix phx.server
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

[info] Running JenkinsHookProxy.Web.Endpoint with Cowboy using http://0.0.0.0:4000
Interactive Elixir (1.4.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 16:37:04 - info: compiled 6 files into 2 files, copied 3 in 811 ms

nil
iex(2)> JenkinsHookProxy.Jenkins.create_callback(%{host_id: "test", token: "abc123", callback_url: "http://192.168.2.231:8020/github-webhook"})
[debug] QUERY OK db=5.2ms
INSERT INTO `jenkins_callbacks` (`callback_url`,`host_id`,`token`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["http://192.168.2.231:8020/github-webhook", "test", "abc123", {{2017, 3, 30}, {8, 46, 9, 810223}}, {{2017, 3, 30}, {8, 46, 9, 816571}}]
{:ok,
 %JenkinsHookProxy.Jenkins.Callback{__meta__: #Ecto.Schema.Metadata<:loaded, "jenkins_callbacks">,
  callback_url: "http://192.168.2.231:8020/github-webhook", host_id: "test",
  id: 3, inserted_at: ~N[2017-03-30 08:46:09.810223], token: "abc123",
  updated_at: ~N[2017-03-30 08:46:09.816571]}}
iex(3)> 


创建运行在内网的项目 Forwarder (nodejs)

启动另外一个命令行窗口.

创建项目目录

$ mkdir forwarder
$ cd forwarder

创建package.json

$ vi package

{
  "name": "forwarder",
  "version": "0.0.1",
  "private": false
}

添加依赖项

phoneix的js是用ES6风格写的,因此需要引入babel转换。

$ npm install --save babel-cli 
$ npm install --save babel-preset-env 
$ npm install --save babel-preset-es2015
$ npm install --save babel-preset-es2016
$ npm install --save ws
$ npm install --save node-fetch

jenkins_hook_proxy中复制需要的js

$ cp <jenkins_hook_proxy>/assets/js/socket.js .
$ cp < jenkins_hook_proxy>/deps/phoenix/assets/js/phoenix.js .

修改 phoenix.js

phoenix.js本身是运行在浏览器中的,nodejs并没有window对象,也没有window.WebSocket。
因此我们需要修改phoenix.js注入window及window.WebSocket。

把下面这句话插入到 192行 class Push { 之前。

let window = {WebSocket: require('ws')};

class Push {

编辑 socket.js

在socket.js中实现channel订阅与对服务器传回的Jenkins_msg事件进行处理。

$ vi socket.js

// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "web/static/js/app.js".

// To use Phoenix channels, the first step is to import Socket
// and connect at the socket path in "lib/my_app/endpoint.ex":
import {Socket} from "./phoenix";

import fetch from "node-fetch";

let socket = new Socket("ws://localhost:4000/socket", {params: {token: ''}})

// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
// which authenticates the session and assigns a `:current_user`.
// If the current user exists you can assign the user's token in
// the connection for use in the layout.
//
// In your "web/router.ex":
//
//     pipeline :browser do
//       ...
//       plug MyAuth
//       plug :put_user_token
//     end
//
//     defp put_user_token(conn, _) do
//       if current_user = conn.assigns[:current_user] do
//         token = Phoenix.Token.sign(conn, "user socket", current_user.id)
//         assign(conn, :user_token, token)
//       else
//         conn
//       end
//     end
//
// Now you need to pass this token to JavaScript. You can do so
// inside a script tag in "web/templates/layout/app.html.eex":
//
//     <script>window.userToken = "<%= assigns[:user_token] %>";</script>
//
// You will need to verify the user token in the "connect/2" function
// in "web/channels/user_socket.ex":
//
//     def connect(%{"token" => token}, socket) do
//       # max_age: 1209600 is equivalent to two weeks in seconds
//       case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
//         {:ok, user_id} ->
//           {:ok, assign(socket, :user, user_id)}
//         {:error, reason} ->
//           :error
//       end
//     end
//
// Finally, pass the token on connect as below. Or remove it
// from connect if you don't care about authentication.

socket.connect()

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("jenkins:lobby", {})

channel.on("jenkins_msg", payload => {
  channel.lastPayload = payload;
  let url = payload.jenkins.callback_url;
  let headers = Object.assign({}, {"content-type": "application/json"}, payload.github_headers);

  console.log(`[${Date()}]\n ${JSON.stringify(payload, null, "  ")}`);
  fetch(url, {
    method: "POST",
    headers: headers,
    body: JSON.stringify(payload.body)
  })
    .then( (e) => console.log(e) );
});

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export {socket, channel};

创建 app.js

$ vi app.js

import socket from "./socket"

添加.babelrc

$ vi .babelrc

{
  "presets": ["env", "es2015"]
}

运行 forwarder 测试

ES6语法,需要用babel-node.js app.js 来转译执行, 并看到结果。

$ ./node_modules/babel-cli/bin/babel-node.js app.js
Joined successfully {}

看到上面的 Joined successfully {} ,说明已经成功连接上Jenkins_hook_proxy。

在jenkins_hook_proxy的运行控制台中,也能看到相应的输出:

iex(3)> [info] JOIN "jenkins:lobby" to JenkinsHookProxy.Web.JenkinsChannel
  Transport:  Phoenix.Transports.WebSocket
  Parameters: %{}
[info] Replied jenkins:lobby :ok

再开一个命令行窗口,模拟github发起请求

$ curl -H "Content-Type: application/json" -d '{"foo": "bar"}' http://localhost:4000/api/callbacks/test/abc123
{"result":"ok"}                                                                                                         

jenkins_hook_proxy运行输出可以看到日志信息:

iex(12)> [info] POST /api/callbacks/test/abc123
[debug] Processing with JenkinsHookProxy.Web.CallbackController.create/2
  Parameters: %{"foo" => "bar", "host_id" => "test", "token" => "abc123"}
  Pipelines: [:api]
[debug] QUERY OK source="jenkins_callbacks" db=0.5ms
SELECT j0.`id`, j0.`callback_url`, j0.`host_id`, j0.`token`, j0.`inserted_at`, j0.`updated_at` FROM `jenkins_callbacks` AS j0 WHERE (j0.`host_id` = ?) ["test"]
[info] Sent 200 in 998µs

forwarder的运行输出也能看到日志信息:

[Thu Mar 30 2017 17:10:25 GMT+0800 (CST)]
 {
  "raw_headers": {
    "user-agent": "curl/7.51.0",
    "host": "localhost:4000",
    "content-type": "application/json",
    "content-length": "14",
    "accept": "*/*"
  },
  "jenkins": {
    "callback_url": "http://192.168.2.231:8020/github-webhook"
  },
  "github_headers": {
    "user-agent": "curl/7.51.0"
  },
  "body": {
    "foo": "bar"
  }
}
(node:47869) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 2): FetchError: request to http://192.168.2.231:8020/github-webhook failed, reason: connect ECONNREFUSED 192.168.2.231:8020

最后这个错误,是因为 http://192.168.2.231:8020/github-webhook jenkins服务器没打开。
但已经能说明,整条链路是正常的了。

测试无效token

$ curl -H "Content-Type: application/json" -d '{"foo": "bar"}' http://localhost:4000/api/callbacks/test/abc   
{"error":"token not matched."}                                                                                      

总结

真实环境部署
jenkins_hook_proxy 运行在外网云主机上;

forwarder 运行在内网主机上,可以直接在jenkins的机器上跑。

反正我们已经是使用这么使用了。

代码可以上github获取
https://github.com/edwardzhou/jenkins_hook_proxy
https://github.com/edwardzhou/jenkins-forwarder

注意

上面的代码是十几分钟撸出来的玩具,帮助我们偿还持续集成的技术债务而已。
对于其他问题和副作用概不承担责任 😜😜😜

推荐阅读更多精彩内容