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.
569 lines
13 KiB
569 lines
13 KiB
3 months ago
|
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"))
|
||
|
}
|