重构项目

main
熊二 3 months ago
parent 571f1b95fe
commit bfdf04eda8
  1. 2
      .air.toml
  2. 10
      build/init.go
  3. 11
      deploy.yml
  4. 75
      entities/build.go
  5. 33
      entities/init.go
  6. 33
      entities/project.go
  7. 32
      entities/user.go
  8. 3
      go.mod
  9. 18
      go.sum
  10. 562
      main.go
  11. 24
      routes/build_project.go
  12. 7
      routes/delete_project.go
  13. 52
      routes/do_login.go
  14. 117
      routes/echo.go
  15. 9
      routes/handle_new_project.go
  16. 51
      routes/init.go
  17. 7
      routes/list_projects.go
  18. 20
      routes/logout.go
  19. 11
      routes/show_home.go
  20. 10
      routes/show_login.go
  21. 10
      routes/show_new_project.go
  22. 10
      routes/show_project.go
  23. 7
      routes/update_project.go
  24. 20
      routes/utils.go
  25. 45
      util/dts/null_string.go
  26. 43
      util/dts/null_time.go
  27. 47
      util/dts/null_uint.go
  28. 0
      util/misc/chdir.go
  29. 0
      util/misc/git_clone.go
  30. 0
      util/misc/git_pull.go
  31. 0
      util/misc/git_reset.go
  32. 0
      util/misc/go_build.go
  33. 0
      util/misc/go_mod_tidy.go
  34. 0
      util/misc/go_test.go
  35. 24
      views/base.gohtml
  36. 70
      views/index.gohtml
  37. 0
      views/login.gohtml

@ -14,7 +14,7 @@ tmp_dir = "tmp"
follow_symlink = false follow_symlink = false
full_bin = "" full_bin = ""
include_dir = [] include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"] include_ext = ["go", "tpl", "tmpl", "html", "gohtml"]
include_file = [] include_file = []
kill_delay = "0s" kill_delay = "0s"
log = "build-errors.log" log = "build-errors.log"

@ -0,0 +1,10 @@
package build
import "gorm.io/gorm"
var db *gorm.DB
func Init(gdb *gorm.DB) error {
db = gdb
return nil
}

@ -1,8 +1,7 @@
name: Deploy # 使用的分支
branch: main
vcs:
branch: main
# 构建步骤
steps: steps:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -19,6 +18,10 @@ steps:
- name: Build step - name: Build step
run: "npm install && npm run build" run: "npm install && npm run build"
env:
VERSION: 1.0
USERNAME: root
PASSWORD: pwd
- name: Upload to Deno Deploy - name: Upload to Deno Deploy
uses: denoland/deployctl@v1 uses: denoland/deployctl@v1

@ -0,0 +1,75 @@
package entities
import (
"devops/util/dts"
"time"
)
// BuildStatus 构建状态
type BuildStatus string
const (
BuildPending BuildStatus = "pending" // 等待运行
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"`
// 使用的仓库分支名称
Branch string `json:"branch"`
// 构建时,项目最后一次提交的 commit 标识
CommitId string `json:"commitId"`
// 构建时,项目最后一次提交的 commit 信息
CommitText string `json:"commitText"`
// 构建项目时的标准输出内容(不包含构建步骤的)
Stdout string `json:"stdout"`
// 构建项目时的错误输出内容(不包含构建步骤的)
Stderr string `json:"startedError"`
// 项目构建步骤
Steps []BuildStep `json:"steps"`
// 启动构建程序的用户的编号,如果是通过 webhook 修改,会被置空
StartedBy dts.NullUint `json:"startedBy"`
// 启动构建项目时的时间
StartedAt time.Time `json:"startedAt"`
// 手动停止该项目构建的用户的编号,自动完成会将该值置空。
StoppedBy dts.NullUint `json:"stoppedBy"`
// 手动停止或自动完成该项目构建时的时间
StoppedAt time.Time `json:"stoppedAt"`
// 当前所处状态
Status BuildStatus `json:"status"`
}
// BuildStep 构建步骤日志
type BuildStep 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"`
// 运行构建步骤的命令
Run string `json:"run"`
// 运行构建步骤时的环境变量
Env map[string]string `json:"env" gorm:"serializer:json"`
// 构建步骤的标准输出内容
Stdout string `json:"stdout"`
// 构建步骤的错误输出内容
Stderr string `json:"stderr"`
// 开始该步骤时的时间
StartedAt time.Time `json:"startedAt"`
// 结束该步骤时的时间
StoppedAt time.Time `json:"stoppedAt"`
// 当前所处状态
Status BuildStatus `json:"status"`
}

@ -0,0 +1,33 @@
package entities
import (
"encoding/gob"
"log"
"gorm.io/gorm"
)
func Init(db *gorm.DB) error {
err := db.AutoMigrate(
&User{},
&Project{},
&Build{},
&BuildStep{},
)
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)
}
// 使用 github.com/gorilla/sessions 需要
gob.Register(&User{})
return nil
}

@ -0,0 +1,33 @@
package entities
import (
"devops/util/dts"
"time"
)
// Project 项目
type Project struct {
// 项目编号,推荐使用 ObjectId 算法生成,也可以使用 UUID/NanoID 等。
ID string `json:"id" gorm:"primaryKey"`
// 项目名称
Name string `json:"name" gorm:"unique"`
// 项目别称,显示使用
Alias string `json:"alias" 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 dts.NullUint `json:"updatedBy"`
// 上一次修改项目信息的时间
UpdatedAt dts.NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"`
}

@ -0,0 +1,32 @@
package entities
import (
"errors"
"time"
"gorm.io/gorm"
"zestack.dev/misc"
)
// 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("缺少密码")
}

@ -11,6 +11,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11 gorm.io/gorm v1.25.11
zestack.dev/env v0.0.0-20240108012311-632035163eec
zestack.dev/misc v0.0.0-20240815120320-73293b130b9f zestack.dev/misc v0.0.0-20240815120320-73293b130b9f
zestack.dev/slim v0.0.0-20240815120229-098d3294fc1a zestack.dev/slim v0.0.0-20240815120229-098d3294fc1a
) )
@ -19,6 +20,7 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
@ -27,6 +29,7 @@ require (
golang.org/x/sync v0.6.0 // indirect golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect golang.org/x/text v0.15.0 // indirect
zestack.dev/cast v0.0.0-20240523001414-e34212374a23 // indirect
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f // indirect zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f // indirect
zestack.dev/log v0.0.0-20240523001421-24d58305cd03 // indirect zestack.dev/log v0.0.0-20240523001421-24d58305cd03 // indirect
) )

@ -5,6 +5,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@ -76,11 +77,14 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
@ -101,8 +105,11 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@ -124,6 +131,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/wader/gormstore/v2 v2.0.3 h1:/29GWPauY8xZkpLnB8hsp+dZfP3ivA9fiDw1YVNTp6U= github.com/wader/gormstore/v2 v2.0.3 h1:/29GWPauY8xZkpLnB8hsp+dZfP3ivA9fiDw1YVNTp6U=
github.com/wader/gormstore/v2 v2.0.3/go.mod h1:sr3N3a8F1+PBc3fHoKaphFqDXLRJ9Oe6Yow0HxKFbbg= github.com/wader/gormstore/v2 v2.0.3/go.mod h1:sr3N3a8F1+PBc3fHoKaphFqDXLRJ9Oe6Yow0HxKFbbg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
@ -204,8 +213,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@ -226,8 +236,12 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
zestack.dev/cast v0.0.0-20240523001414-e34212374a23 h1:xlcaGxt2tomZX91jzN7mk4vyy+d3fImb7FQcr8hd9Aw=
zestack.dev/cast v0.0.0-20240523001414-e34212374a23/go.mod h1:Z0WKru8M3FN1fDhqVEzw+I8MNNOPo3bc9lzB6oEJW6s=
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f h1:5tSc5qVy2f6vyG5LVRETRe8tAFeZKxcjFsl0Jy7rRUQ= zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f h1:5tSc5qVy2f6vyG5LVRETRe8tAFeZKxcjFsl0Jy7rRUQ=
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f/go.mod h1:lHvP85VRaqzm4E/9Q9kW/WmEnEHL4PQ9Fls5xDah3zI= zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f/go.mod h1:lHvP85VRaqzm4E/9Q9kW/WmEnEHL4PQ9Fls5xDah3zI=
zestack.dev/env v0.0.0-20240108012311-632035163eec h1:VOz6k4dJeHHoozGrF6ISIGaR7amRXGCJfMH+0amYrf0=
zestack.dev/env v0.0.0-20240108012311-632035163eec/go.mod h1:TgAagxCpU5oBFIHQSSuVcGw3ygfNvm3CSU92t/ufEWA=
zestack.dev/log v0.0.0-20240523001421-24d58305cd03 h1:ZNnBVHIiV4R2SBypa24SLuhkrc9unlvPdXg/u0ZqSro= zestack.dev/log v0.0.0-20240523001421-24d58305cd03 h1:ZNnBVHIiV4R2SBypa24SLuhkrc9unlvPdXg/u0ZqSro=
zestack.dev/log v0.0.0-20240523001421-24d58305cd03/go.mod h1:1ZoaLtIYHxx/J7jFoQpNLxsCIcpBZ1NQWFjafTEBzN8= zestack.dev/log v0.0.0-20240523001421-24d58305cd03/go.mod h1:1ZoaLtIYHxx/J7jFoQpNLxsCIcpBZ1NQWFjafTEBzN8=
zestack.dev/misc v0.0.0-20240815120320-73293b130b9f h1:77RL7xP7l0kV8s12EDo/JcQDbYuuwfbBO3JhYu9V8MI= zestack.dev/misc v0.0.0-20240815120320-73293b130b9f h1:77RL7xP7l0kV8s12EDo/JcQDbYuuwfbBO3JhYu9V8MI=

@ -1,493 +1,20 @@
package main package main
import ( import (
"bytes" "devops/build"
"database/sql" "devops/entities"
"database/sql/driver" "devops/routes"
"encoding/gob"
"encoding/json"
"errors"
"gopkg.in/yaml.v3"
"html/template" "html/template"
"io" "io"
"log" "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/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"zestack.dev/misc" "gorm.io/gorm/schema"
"zestack.dev/env"
"zestack.dev/slim" "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 { type Template struct {
templates *template.Template templates *template.Template
} }
@ -498,71 +25,48 @@ func (t *Template) Render(c slim.Context, w io.Writer, name string, data any) er
func main() { func main() {
var err error var err error
db, err = gorm.Open(sqlite.Open("devops.db"), &gorm.Config{}) var db *gorm.DB
if err != nil {
// 初始化环境变量
if err = env.Init(); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
err = db.AutoMigrate(
&User{}, // 初始化数据库
&Project{}, if db, err = gorm.Open(sqlite.Open(env.String("DB_DSN", "devops.db")), &gorm.Config{
&Build{}, NamingStrategy: schema.NamingStrategy{
&BuildLog{}, TablePrefix: env.String("DB_TABLE_PREFIX", "do_"),
) },
if err != nil { TranslateError: env.Bool("DB_TRANSLATE_ERROR", true),
}); err != nil {
log.Fatalln(err) log.Fatalln(err)
} } else if err = entities.Init(db); err != nil {
err = db.Model(&User{}).FirstOrCreate(&User{
ID: 1,
Username: "admin",
RawPassword: "111111",
}, User{ID: 1}).Error
if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
upgrader = &websocket.Upgrader{} // 启动构建程序
clients = new(sync.Map) if err = build.Init(db); err != nil {
broadcast = make(chan []byte) log.Fatalln(err)
store = gormstore.New(db, []byte("secret")) }
gob.Register(&User{})
// 初始化 http
s := slim.New() s := slim.New()
s.Debug = true s.Debug = true
s.Renderer = &Template{templates: template.Must(template.ParseGlob("views/*.gohtml"))} s.Renderer = &Template{templates: template.Must(template.ParseGlob("views/*.gohtml"))}
s.ResetRouterCreator(func(s *slim.Slim) slim.Router {
return slim.NewRouter(slim.RouterConfig{
RoutingTrailingSlash: true,
})
})
s.Use(slim.Logging()) s.Use(slim.Logging())
s.Use(slim.Recovery()) s.Use(slim.Recovery())
s.Use(slim.Static("public")) s.Use(slim.Static("public"))
s.File("/login", "login.html") // 注册路由
s.POST("/login", handleLogin) if err = routes.Init(db, s); err != nil {
s.GET("/logout", handleLogout) log.Fatalln(err)
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")) log.Fatalln(s.Start(":5000"))
} }

@ -0,0 +1,24 @@
package routes
import (
"zestack.dev/slim"
)
type BuildConfig struct {
Branch string `json:"branch" yaml:"branch"`
Steps []StepConfig `json:"steps" yaml:"steps"`
}
type StepConfig struct {
Name string `json:"name" yaml:"name"`
Run string `json:"run" yaml:"run"`
Env map[string]string `json:"env" yaml:"env"`
}
type VCSConfig struct {
Branch string `json:"branch" yaml:"branch"`
}
func buildProject(c slim.Context) error {
return nil
}

@ -0,0 +1,7 @@
package routes
import "zestack.dev/slim"
func deleteProject(c slim.Context) error {
return nil
}

@ -0,0 +1,52 @@
package routes
import (
"devops/entities"
"errors"
"net/http"
"gorm.io/gorm"
"zestack.dev/misc"
"zestack.dev/slim"
)
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(http.StatusMovedPermanently, "/login?error=用户名不能为空")
}
if req.Password == "" {
return c.Redirect(http.StatusMovedPermanently, "/login?error=登录密码不能为空")
}
var user entities.User
err := db.
Model(&entities.User{}).
Where("username", req.Username).
First(&user).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名或密码错误")
}
return err
}
if !misc.PasswordVerify(req.Password, user.Password) {
return c.Redirect(http.StatusMovedPermanently, "/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, "/")
}

@ -0,0 +1,117 @@
package routes
import (
"bytes"
"time"
"github.com/gorilla/websocket"
"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
)
func handleNotify(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
}

@ -0,0 +1,9 @@
package routes
import (
"zestack.dev/slim"
)
func handleNewProject(c slim.Context) error {
return nil
}

@ -0,0 +1,51 @@
package routes
import (
"sync"
"zestack.dev/env"
"github.com/gorilla/sessions"
"github.com/gorilla/websocket"
"github.com/wader/gormstore/v2"
"gorm.io/gorm"
"zestack.dev/slim"
)
var (
newline = []byte{'\n'}
space = []byte{' '}
upgrader *websocket.Upgrader
clients *sync.Map
broadcast chan []byte
store sessions.Store
db *gorm.DB
)
func Init(gdb *gorm.DB, s *slim.Slim) error {
db = gdb
upgrader = &websocket.Upgrader{}
clients = new(sync.Map)
broadcast = make(chan []byte)
store = gormstore.NewOptions(db, gormstore.Options{
TableName: env.String("DB_TABLE_PREFIX", "do_") + "sessions",
}, []byte("secret"))
s.GET("/login", showLogin)
s.POST("/login", handleLogin)
s.Route("/", func(r slim.RouteCollector) {
r.Use(authSession)
r.GET("/", showHome)
r.GET("/logout", handleLogout)
r.GET("/notify", handleNotify)
r.GET("/projects", listProjects)
r.GET("/new-projects", showNewProject)
r.POST("/new-projects", handleNewProject)
r.GET("/projects/:id", showProject)
r.DELETE("/projects/:id", deleteProject)
r.PUT("/projects/:id", updateProject)
r.POST("/build", buildProject)
})
return nil
}

@ -0,0 +1,7 @@
package routes
import "zestack.dev/slim"
func listProjects(c slim.Context) error {
return nil
}

@ -0,0 +1,20 @@
package routes
import (
"net/http"
"zestack.dev/slim"
)
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, "/")
}

@ -0,0 +1,11 @@
package routes
import (
"net/http"
"zestack.dev/slim"
)
func showHome(c slim.Context) error {
return c.Render(http.StatusOK, "index.gohtml", "ws://"+c.Request().Host+"/notify")
}

@ -0,0 +1,10 @@
package routes
import (
"net/http"
"zestack.dev/slim"
)
func showLogin(c slim.Context) error {
return c.Render(http.StatusOK, "login.gohtml", slim.Map{})
}

@ -0,0 +1,10 @@
package routes
import (
"net/http"
"zestack.dev/slim"
)
func showNewProject(c slim.Context) error {
return c.Render(http.StatusOK, "new_project.gohtml", slim.Map{})
}

@ -0,0 +1,10 @@
package routes
import (
"net/http"
"zestack.dev/slim"
)
func showProject(c slim.Context) error {
return c.Render(http.StatusOK, "project.gohtml", slim.Map{})
}

@ -0,0 +1,7 @@
package routes
import "zestack.dev/slim"
func updateProject(c slim.Context) error {
return nil
}

@ -0,0 +1,20 @@
package routes
import (
"devops/entities"
"net/http"
"zestack.dev/slim"
)
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"].(*entities.User)
if !ok {
return c.Redirect(http.StatusMovedPermanently, "/login")
}
c.Set("user", user)
return next(c)
}

@ -0,0 +1,45 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
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
}

@ -0,0 +1,43 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
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
}

@ -0,0 +1,47 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
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
}

@ -0,0 +1,24 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{{block "title" .}}{{end}}</title>
{{block "head" .}}{{end}}
</head>
<body>
<header>
<div>
<div>Overview</div>
<div>Projects</div>
<div>Settings</div>
<div>Docs</div>
</div>
</header>
{{block "content" .}}{{end}}
</body>
</html>

@ -1,43 +1,43 @@
<!DOCTYPE html> {{template "base.gohtml"}}
<html lang="zh-Hans">
<head> {{define "title"}}Home{{end}}
<meta charset="utf-8">
<title></title> {{define "head"}}
<script> <script>
window.addEventListener("load", function() { window.addEventListener("load", function () {
var output = document.getElementById("output"); var output = document.getElementById("output");
var input = document.getElementById("input"); var input = document.getElementById("input");
var ws; var ws;
var print = function(message) { var print = function (message) {
var d = document.createElement("div"); var d = document.createElement("div");
d.textContent = message; d.textContent = message;
output.appendChild(d); output.appendChild(d);
output.scroll(0, output.scrollHeight); output.scroll(0, output.scrollHeight);
}; };
document.getElementById("open").onclick = function(evt) { document.getElementById("open").onclick = function (evt) {
if (ws) { if (ws) {
return false; return false;
} }
ws = new WebSocket("{{.}}"); ws = new WebSocket("{{.}}");
ws.onopen = function(evt) { ws.onopen = function (evt) {
print("OPEN"); print("OPEN");
} }
ws.onclose = function(evt) { ws.onclose = function (evt) {
print("CLOSE"); print("CLOSE");
ws = null; ws = null;
} }
ws.onmessage = function(evt) { ws.onmessage = function (evt) {
print("RESPONSE: " + evt.data); print("RESPONSE: " + evt.data);
} }
ws.onerror = function(evt) { ws.onerror = function (evt) {
print("ERROR: " + evt.data); print("ERROR: " + evt.data);
} }
return false; return false;
}; };
document.getElementById("send").onclick = function(evt) { document.getElementById("send").onclick = function (evt) {
if (!ws) { if (!ws) {
return false; return false;
} }
@ -46,7 +46,7 @@
return false; return false;
}; };
document.getElementById("close").onclick = function(evt) { document.getElementById("close").onclick = function (evt) {
if (!ws) { if (!ws) {
return false; return false;
} }
@ -55,22 +55,26 @@
}; };
}); });
</script> </script>
</head> {{end}}
<body>
<table> {{define "content"}}
<tr><td valign="top" width="50%"> <table>
<p>Click "Open" to create a connection to the server, <tr>
"Send" to send a message to the server and "Close" to close the connection. <td valign="top" width="50%">
You can change the message and send multiple times. <p>Click "Open" to create a connection to the server,
<p> "Send" to send a message to the server and "Close" to close the connection.
<form> You can change the message and send multiple times.
<button id="open">Open</button> <p>
<button id="close">Close</button> <form>
<p><input id="input" type="text" value="Hello world!"> <button id="open">Open</button>
<button id="send">Send</button> <button id="close">Close</button>
</form> <p><input id="input" type="text" value="Hello world!">
</td><td valign="top" width="50%"> <button id="send">Send</button>
<div id="output" style="max-height: 70vh;overflow-y: scroll;"></div> </form>
</td></tr></table> </td>
</body> <td valign="top" width="50%">
</html> <div id="output" style="max-height: 70vh;overflow-y: scroll;"></div>
</td>
</tr>
</table>
{{end}}
Loading…
Cancel
Save