You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
devops/main.go

568 lines
13 KiB

package main
import (
"bytes"
"database/sql"
"database/sql/driver"
"encoding/gob"
"encoding/json"
"errors"
"gopkg.in/yaml.v3"
"html/template"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gorilla/sessions"
"github.com/gorilla/websocket"
"github.com/wader/gormstore/v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"zestack.dev/misc"
"zestack.dev/slim"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 512
)
var (
newline = []byte{'\n'}
space = []byte{' '}
upgrader *websocket.Upgrader
clients *sync.Map
broadcast chan []byte
store sessions.Store
db *gorm.DB
)
type NullString sql.NullString
// Scan implements the [Scanner] interface.
func (ns *NullString) Scan(value any) error {
ss := new(sql.NullString)
err := ss.Scan(value)
ns.String = ss.String
ns.Valid = ss.Valid
return err
}
// Value implements the [driver.Valuer] interface.
func (ns NullString) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return ns.String, nil
}
func (ns NullString) MarshalJSON() ([]byte, error) {
if ns.Valid {
return json.Marshal(ns.String)
}
return json.Marshal(nil)
}
func (ns *NullString) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
ns.Valid = false
return nil
}
err := json.Unmarshal(b, &ns.String)
if err == nil {
ns.Valid = true
}
return err
}
type NullTime sql.NullTime
func (nt *NullTime) Scan(value any) error {
st := new(sql.NullTime)
err := st.Scan(value)
nt.Time = st.Time
nt.Valid = st.Valid
return err
}
func (nt NullTime) Value() (driver.Value, error) {
if !nt.Valid {
return nil, nil
}
return nt.Time, nil
}
func (nt NullTime) MarshalJSON() ([]byte, error) {
if nt.Valid {
return nt.Time.MarshalJSON()
}
return json.Marshal(nil)
}
func (nt *NullTime) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
nt.Valid = false
return nil
}
err := json.Unmarshal(b, &nt.Time)
if err == nil {
nt.Valid = true
}
return err
}
type NullUint struct {
V uint
Valid bool
}
func (nu *NullUint) Scan(value any) error {
sn := new(sql.Null[uint])
err := sn.Scan(value)
nu.V = sn.V
nu.Valid = sn.Valid
return err
}
func (nu NullUint) Value() (driver.Value, error) {
if !nu.Valid {
return nil, nil
}
// 注意:driver.Value 不支持 uint
return int64(nu.V), nil
}
func (nu NullUint) MarshalJSON() ([]byte, error) {
if nu.Valid {
return json.Marshal(nu.V)
}
return json.Marshal(nil)
}
func (nu *NullUint) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
nu.Valid = false
return nil
}
err := json.Unmarshal(b, &nu.V)
if err == nil {
nu.Valid = true
}
return err
}
// User 用户
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"unique"`
RawPassword string `json:"-" gorm:"-"` // 原始密码
Password string `json:"password"`
Disabled bool `json:"disabled" gorm:"default:false"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (u *User) BeforeCreate(_ *gorm.DB) error {
if u.RawPassword != "" {
hash, err := misc.PasswordHash(u.RawPassword)
if err != nil {
return err
}
u.Password = hash
return nil
}
return errors.New("缺少密码")
}
// Project 项目
type Project struct {
// 项目编号,推荐使用 ObjectId 算法生成,也可以使用 UUID/NanoID 等。
ID string `json:"id" gorm:"primaryKey"`
// 项目名称
Name string `json:"name" gorm:"unique"`
// 项目介绍
Intro string `json:"intro"`
// 项目网站
Website string `json:"website"`
// 项目的仓库地址,比如 GitHub、Gitee、Gitlab、Gitea 或自建的仓库,
// 如果是私有仓库,应该携带授权信息(建议使用私有令牌而不是账号密码)。
RepositoryURL string `json:"repositoryURL"`
// 用于构建的仓库分支,若置空则使用主分支
Branch string `json:"branch"`
// 创建项目的用户编号
CreatedBy uint `json:"createdBy"`
// 创建项目时的时间
CreatedAt time.Time `json:"createdAt"`
// 上一次修改项目信息的用户编号
UpdatedBy NullUint `json:"updatedBy"`
// 上一次修改项目信息的时间
UpdatedAt NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"`
}
// BuildStatus 构建状态
type BuildStatus string
const (
BuildRunning BuildStatus = "running" // 正在运行
BuildSuccess BuildStatus = "success" // 构建成功
BuildFailure BuildStatus = "failure" // 构建失败
BuildAborted BuildStatus = "aborted" // 构建中断
)
// Build 项目构建
type Build struct {
// 构建编号
ID string `json:"id" gorm:"primaryKey"`
// 构建项目
ProjectID string `json:"projectId"`
// 关联项目,如果项目删除也删除
Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
// 构建时,项目最后一次提交的 commit 标识
CommitId string `json:"commitId"`
// 构建时,项目最后一次提交的 commit 信息
CommitText string `json:"commitText"`
// 非构建步骤失败时的错误信息
Error NullString `json:"startedError"`
// 启动构建程序的用户的编号,如果是通过 webhook 修改,会被置空
StartedBy NullUint `json:"startedBy"`
// 启动构建项目时的时间
StartedAt time.Time `json:"startedAt"`
// 手动停止或自动完成该项目构建时的时间
StoppedAt NullTime `json:"stoppedAt"`
// 手动停止该项目构建的用户的编号,自动完成会将该值置空。
StoppedBy NullUint `json:"stoppedBy"`
// 当前所处状态
Status BuildStatus `json:"status"`
// 步骤日志
Logs []BuildLog `json:"logs"`
// 创建构建步骤的用户编号,如果是通过 webhook 修改,会被置空
CreatedBy NullUint `json:"createdBy"`
// 创建构建步骤时的时间
CreatedAt time.Time `json:"createdAt"`
// 上一次修改构建步骤信息的用户编号,如果是通过 webhook 修改,会被置空
UpdatedBy NullUint `json:"updatedBy"`
// 上一次修改构建步骤信息的时间
UpdatedAt NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"`
}
// BuildLog 构建步骤日志
type BuildLog struct {
// 日志编号
Id uint `json:"id" gorm:"primaryKey"`
// 归属的构建编号
BuildID string `json:"buildId"`
// 关联项目,如果项目删除也删除
Build *Build `json:"build,omitempty" gorm:"foreignKey:BuildID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
// 构建步骤名称
Name string `json:"name"`
// 该构建步骤使用的程序
Uses string `json:"uses"`
// 该构建步骤时传递给程序的参数
With map[string]string `json:"with" gorm:"serializer:json"`
// 程序的运行日志
Logs []string `json:"logs" gorm:"serializer:json"`
// 当前所处状态
Status BuildStatus `json:"status"`
// 开始该步骤时的时间
StartedAt time.Time `json:"startedAt"`
// 结束该步骤时的时间
StoppedAt time.Time `json:"stoppedAt"`
}
type BuildConfig struct {
Name string `json:"name" yaml:"name"`
VCS VCSConfig `json:"vcs" yaml:"vcs"`
Steps []StepConfig `json:"steps" yaml:"steps"`
}
type VCSConfig struct {
Branch string `json:"branch" yaml:"branch"`
}
type StepConfig struct {
Name string `json:"name" yaml:"name"`
Uses string `json:"uses" yaml:"uses"`
With map[string]string `json:"with" yaml:"with"`
}
func Fail(c slim.Context, msg string, code ...int) error {
statusCode := http.StatusBadRequest
if len(code) > 0 {
statusCode = code[0]
}
return c.JSON(statusCode, slim.Map{
"ok": false,
"msg": msg,
})
}
func AuthSession(c slim.Context, next slim.HandlerFunc) error {
sess, err := store.Get(c.Request(), "session-key")
if err != nil {
return err
}
user, ok := sess.Values["user"].(*User)
if !ok {
return Fail(c, "请登录", http.StatusUnauthorized)
}
c.Set("user", user)
return next(c)
}
func handleLogin(c slim.Context) error {
var req struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
}
if err := c.Bind(&req); err != nil {
return err
}
if req.Username == "" {
return c.Redirect(301, "/login?error=用户名不能为空")
}
if req.Password == "" {
return c.Redirect(301, "/login?error=登录密码不能为空")
}
var user User
err := db.Model(&User{}).Where("username", req.Username).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Redirect(301, "/login?error=用户名或密码错误")
}
return err
}
if !misc.PasswordVerify(req.Password, user.Password) {
return c.Redirect(301, "/login?error=用户名或密码错误")
}
sess, err := store.Get(c.Request(), "session-key")
if err != nil {
return err
}
sess.Values["user"] = &user
err = sess.Save(c.Request(), c.Response())
if err != nil {
return err
}
return c.Redirect(http.StatusMovedPermanently, "/")
}
func handleLogout(c slim.Context) error {
// Get a session. We're ignoring the error resulted from decoding an
// existing session: Get() always returns a session, even if empty.
session, _ := store.Get(c.Request(), "session-name")
// Set some session values.
session.Values["foo"] = nil
// Save it before we write to the response/return from the handler.
err := session.Save(c.Request(), c.Response())
if err != nil {
return err
}
return c.Redirect(http.StatusMovedPermanently, "/")
}
func handleEcho(c slim.Context) error {
conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
clients.Store(conn, struct{}{})
defer clients.Delete(conn)
// TODO 使用 pool 实现复用
exit := make(chan error)
defer close(exit)
stop := func(err error) {
select {
case <-exit:
default:
exit <- err
}
}
go func() {
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, ex := conn.ReadMessage()
if ex != nil {
if websocket.IsUnexpectedCloseError(ex, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
stop(ex)
} else {
stop(nil)
}
return
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
broadcast <- message
}
}()
go func() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
conn.Close()
}()
for {
for {
select {
case message, ok := <-broadcast:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
conn.WriteMessage(websocket.CloseMessage, []byte{})
stop(nil)
return
}
w, err2 := conn.NextWriter(websocket.TextMessage)
if err2 != nil {
stop(err2)
return
}
w.Write(message)
// Add queued chat messages to the current websocket message.
n := len(broadcast)
for i := 0; i < n; i++ {
w.Write(newline)
w.Write(<-broadcast)
}
if err3 := w.Close(); err3 != nil {
stop(err3)
return
}
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err4 := conn.WriteMessage(websocket.PingMessage, nil); err4 != nil {
stop(err4)
return
}
}
}
}
}()
return <-exit
}
func handleStart(c slim.Context) error {
cmd := exec.Command("go")
cmd.Stdout = c.Response()
cmd.Stderr = c.Response()
cmd.Start()
cmd.Wait()
return nil
}
func handleAbort(c slim.Context) error {
return c.String(200, "ok")
}
func handleHome(c slim.Context) error {
return c.Render(http.StatusOK, "index.gohtml", "ws://"+c.Request().Host+"/echo")
}
type Template struct {
templates *template.Template
}
func (t *Template) Render(c slim.Context, w io.Writer, name string, data any) error {
return t.templates.ExecuteTemplate(w, name, data)
}
func main() {
var err error
db, err = gorm.Open(sqlite.Open("devops.db"), &gorm.Config{})
if err != nil {
log.Fatalln(err)
}
err = db.AutoMigrate(
&User{},
&Project{},
&Build{},
&BuildLog{},
)
if err != nil {
log.Fatalln(err)
}
err = db.Model(&User{}).FirstOrCreate(&User{
ID: 1,
Username: "admin",
RawPassword: "111111",
}, User{ID: 1}).Error
if err != nil {
log.Fatalln(err)
}
upgrader = &websocket.Upgrader{}
clients = new(sync.Map)
broadcast = make(chan []byte)
store = gormstore.New(db, []byte("secret"))
gob.Register(&User{})
s := slim.New()
s.Debug = true
s.Renderer = &Template{templates: template.Must(template.ParseGlob("views/*.gohtml"))}
s.Use(slim.Logging())
s.Use(slim.Recovery())
s.Use(slim.Static("public"))
s.File("/login", "login.html")
s.POST("/login", handleLogin)
s.GET("/logout", handleLogout)
s.GET("/echo", handleEcho)
s.GET("/", handleHome)
s.Route("/p/", func(sub slim.RouteCollector) {
sub.Use(AuthSession)
sub.POST(":id/start", handleStart)
sub.POST(":id/abort", handleAbort)
sub.GET("*", handleStart)
})
s.GET("/~", func(c slim.Context) error {
file, err := os.ReadFile("deploy.yml")
if err != nil {
return err
}
var config BuildConfig
err = yaml.Unmarshal(file, &config)
if err != nil {
return err
}
return c.JSONPretty(200, config, " ")
})
//http.HandleFunc("/echo", handleWebsocket)
//http.HandleFunc("/restart", restart)
//http.ListenAndServe(":5000", nil)
log.Fatalln(s.Start(":5000"))
}