Published on

私有 GitLab 佈署 Netlify Previews

Authors
  • avatar
    Name
    Rick Jiang
    Twitter

最近處理的專案,為了讓 Code Review 及整合測試更加流暢,讓有人提 Merge Request 的階段時可以佈署預覽網站,所以想到了可以利用 Netlify 來達成,但是佈署專案時如果要支援 Self-Hosted GitLab 是需要升級成 Pro 以上的方案,秉持著客家精神還好發現了可以使用 Netlify CLI 來達成,所以這邊只要寫好 GitLab CI/CD 配置檔就可以達成

這個專案是使用 Vue.js 採前後端分離的開發方式,所以在第 4、5 行分別加了一些設定來避免 Netlify 出現 CORS 及頁面重整時會報 404 的問題,這邊要注意 _headers_redirects 要與 index.html 擺在一起,更多的 Netlify 客製設定可以參考官方文件

gitlab-ci.yml
deploy:preview:
  stage: deploy
  script:
    - npm install
    - sed -i 's/""/"https:\/\/192.168.1.50"/g' config/prod.env.js # Netlify <ssl> 192.168.1.50 <proxy> target server
    - npm run build
    - echo $'/* \n Access-Control-Allow-Origin':' *' > dist/_headers # Fix CORS on Netlify
    - echo $'/* /index.html 200' > dist/_redirects # Fix Page Not Found on Reload on Netlify
    - npm install netlify-cli --save-dev
    - npx netlify deploy --site $NETLIFY_SITE_ID --auth $NETLIFY_AUTH_TOKEN --dir dist/ | tee netlify_output.txt
    - if [ -z ${CI_MERGE_REQUEST_IID} ]; then exit 0; fi
    - export DRAFT_URL=$(cat netlify_output.txt | grep 'Draft.*https' | sed 's/\[39m//g' | awk -F " " '{print $NF}')
    - which curl || ( apk add --update --no-cache curl )
    - 'curl -k --request POST
      --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN"
      --data-urlencode "body=✔️ **Deploy Preview ready for [Pipeline $CI_PIPELINE_IID]($CI_PIPELINE_URL)**


      🔨 Explore the source changes: $CI_COMMIT_SHA


      😎 Browse the preview: $DRAFT_URL"
      "https://gitlab/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"'
  only:
    - merge_requests
  when: manual

因為後端 API 是沒有對外服務的,所以第一時間想到使用 Nginx 來做 Reverse Proxy,但是 Netlify 不支援非 SSL 的連線方式,所以可以自簽發憑證來達成,這邊為了讓測試的人可以彈性的決定要連向哪台後端 API 進行測試,我使用 Go 搭了一個 API 來讓 User 即時修改配置,但是對 Nginx 沒那麼熟,所以就用很蠢的方式直接使用 sed 替換修改配置檔再重啟服務

main.go
package main

import (
 "context"
 "fmt"
 "net/http"
 "os/exec"
 "regexp"
 "strings"
 "sync"
 "time"

 "github.com/gin-gonic/gin"
 "github.com/go-redis/redis/v8"
)

type User struct {
 ID    string `json:"id"`
 Value string `json:"value"`
 Message string `json:"msg"`
}

var ctx = context.Background()
var rdb *redis.Client
var mutex sync.Mutex

var ids = []string{"USER1", "USER2", "USER3"}
var defaultValue = "http://192.168.1.150:8443"

func main() {
 rdb = initRedisClient()
 router := gin.Default()

 router.GET("/query", query)
 router.GET("/query/:userId", query)
 router.GET("/update/:userId/:newValue", update)

 router.NoRoute(func(c *gin.Context) {
  c.Redirect(http.StatusPermanentRedirect, "/query")
 })

 router.Run()
}

func initRedisClient() *redis.Client {
 rdb := redis.NewClient(&redis.Options{
  Addr:     "localhost:6379",
  Password: "",
  DB:       8,
 })

 _, err := rdb.Ping(ctx).Result()
 if (err != nil) {
  panic(err)
 }

 for _, id := range ids {
  result, err := rdb.Exists(ctx, id).Result()
  if err != nil {
   panic(err)
  }
  if (result == 0) {
   res, err := rdb.Set(ctx, id, defaultValue, 0).Result()
   if (err != nil) {
    panic(err)
   }
   fmt.Printf("%s init(%s): %s \n", id, defaultValue, res)
  }
 }

 return rdb
}

func query(c *gin.Context) {
 userId := c.Param("userId")
 if userId == "" {
  var users []User

  for _, id := range ids {
   user := User{
    ID:    id,
    Value: rdb.Get(ctx, id).Val(),
   }
   users = append(users, user)
  }
  c.JSON(http.StatusOK, users)
  return
 }

 result, err := rdb.Get(ctx, userId).Result()
 if err != nil {
  c.String(http.StatusOK, "NOT FOUND")
  return
 }
 c.JSON(http.StatusOK, User{ID: userId, Value: result})
}

func update(c *gin.Context) {
 userId := c.Param("userId")
 newValue := c.Param("newValue")
 result, err := rdb.Exists(ctx, userId).Result()
 if err != nil {
  panic(err)
 }
 if (result > 0) {
  ipv4_regex := `^(((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4})`
  match, _ := regexp.MatchString(ipv4_regex, newValue)
  if (match) {
   val := fmt.Sprintf("http://%s:8443", newValue)
   res, err := rdb.Set(ctx, userId, val, 0).Result()
   if (err != nil) {
    panic(err)
   }
   c.JSON(http.StatusOK, User{ID: userId, Value: val, Message: res})
   go reloadNginx()
  } else {
   c.String(http.StatusOK, "WRONG IP ADDRESS")
   return
  }
 } else {
  c.String(http.StatusOK, "NOT FOUND")
  return
 }
}

func reloadNginx() {
 mutex.Lock()

 exec.Command("/bin/sh", "-c", "mv -f /Nginx/default.conf /Nginx/default.conf.last").Run()
 exec.Command("/bin/sh", "-c", "cp -f /Nginx/default.conf.bak /Nginx/default.conf").Run()

 for _, id := range ids {
  value := strings.ReplaceAll(rdb.Get(ctx, id).Val(), "/", "\\/")
  arg := fmt.Sprintf("s/%s/%s/g", id, value)
  exec.Command("sed", "-i", arg, "/Nginx/default.conf").Run()
 }
 cmd, err := exec.Command("/bin/sh", "-c", "docker restart nginx").CombinedOutput()
 if (err != nil) {
  panic(nil)
 }
 fmt.Println(string(cmd))
 time.Sleep(5 * time.Second)
 mutex.Unlock()
}

API 會使用 sed 替換掉配置檔內的佔位符成 User 自己設定的值,如此一來當 User 進到 Nginx 會依照自己的 IP 去到自己所指定的後端主機,反之如果非指定的 User 則預設連到主要的測試主機上

default.conf
server {
  gzip on;
  listen 443 ssl;
  listen  [::]:443;
  server_name 192.168.1.50;

  ssl_certificate /etc/nginx/ssl/nginx.crt;
  ssl_certificate_key /etc/nginx/ssl/nginx.key;


  location ~* / {
  proxy_set_header  Host $host;
  proxy_set_header  X-Real-IP $remote_addr;
  proxy_set_header  X-Forwarded-For $remote_addr;
  proxy_set_header  X-Forwarded-Host $remote_addr;
  proxy_set_header  X-NginX-Proxy true;
  proxy_pass        http://192.168.1.150:8443;

  add_header Access-Control-Allow-Origin * always;
  add_header Access-Control-Allow-Headers *;
  add_header Access-Control-Allow-Methods *;
  add_header Access-Control-Allow-Credentials true;
  if ($request_method = 'OPTIONS') {
    return 204;
  }

  if ($remote_addr = "192.168.22.10") {
    proxy_pass  USER1;
    break;
  }
  if ($remote_addr = "192.168.22.11") {
    proxy_pass  USER2;
    break;
  }
  if ($remote_addr = "192.168.22.12") {
    proxy_pass  USER3;
    break;
  }
  }
}