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")) }