diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb7e051f..0f3c0e8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,10 +72,58 @@ jobs: # # 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的 # 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。 + # + # 顺序是有意的:迁移先跑,跑不过就保持现有版本不动; + # 旧容器改名保留而不是删除,新容器不健康时能原样恢复。 + # 健康检查两条都要过——HTTP 活着不代表数据库通了。 script: | - test -f "${{ secrets.DEMO_CONFIG_PATH }}" || { echo "宿主机配置缺失,中止部署"; exit 1; } - sudo docker rm -f go-admin-api + set -u + CFG="${{ secrets.DEMO_CONFIG_PATH }}" + IMG="${{ env.IMAGE_NAME_TAG }}" + NAME=go-admin-api + PREV="$NAME-prev" + + test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; } + sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }} + sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; } + + # 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的; + # 硬切过去才会得到代码与表对不上的服务。 + if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \ + /main migrate -c /config/settings.yml; then + echo "迁移失败,保持现有版本"; exit 1 + fi + + if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then + sudo docker rm -f "$PREV" >/dev/null 2>&1 || true + sudo docker rename "$NAME" "$PREV" + sudo docker stop "$PREV" >/dev/null + fi + sudo docker run -d -p 8000:8000 \ - -v "${{ secrets.DEMO_CONFIG_PATH }}":/config/settings.yml:ro \ - --name go-admin-api ${{ env.IMAGE_NAME_TAG }} + -v "$CFG":/config/settings.yml:ro \ + --name "$NAME" "$IMG" + + ok=0 + for i in $(seq 1 20); do + sleep 3 + code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true) + if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then + ok=1; echo "健康检查通过(第 $i 次探测)"; break + fi + done + + if [ "$ok" = "1" ]; then + sudo docker rm -f "$PREV" >/dev/null 2>&1 || true + else + echo "健康检查失败,回滚到上一版本" + sudo docker logs --tail 40 "$NAME" 2>&1 || true + sudo docker rm -f "$NAME" >/dev/null 2>&1 || true + if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then + sudo docker rename "$PREV" "$NAME" + sudo docker start "$NAME" >/dev/null + echo "已恢复" + fi + exit 1 + fi diff --git a/cmd/migrate/migration/init.go b/cmd/migrate/migration/init.go index 739e6b40..28f48e0d 100644 --- a/cmd/migrate/migration/init.go +++ b/cmd/migrate/migration/init.go @@ -43,20 +43,28 @@ func (e *Migration) Migrate() { } var err error var count int64 + applied := 0 for _, v := range versions { err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error if err != nil { log.Fatalln(err) } if count > 0 { - log.Println(count) + // Already applied. This used to print the bare count, so a mature + // database wrote a screen of "1" at every start. count = 0 continue } - err = (e.version[v])(e.db.Debug(), v) - if err != nil { - log.Fatalln(err) + log.Printf("applying migration %s", v) + if err = (e.version[v])(e.db.Debug(), v); err != nil { + log.Fatalf("migration %s failed: %v", v, err) } + applied++ + } + if applied == 0 { + log.Println("no migrations to apply") + } else { + log.Printf("applied %d migration(s)", applied) } } diff --git a/common/database/dsn.go b/common/database/dsn.go new file mode 100644 index 00000000..c64ac016 --- /dev/null +++ b/common/database/dsn.go @@ -0,0 +1,47 @@ +package database + +import ( + "net/url" + "regexp" + "strings" +) + +// A DSN carries the database password, and the startup line used to print it +// whole. Anyone with the log file, a log shipper, or a screenshot of a terminal +// had the credential. +// +// Both shapes this project accepts are covered: +// +// mysql user:password@tcp(host:3306)/db?params +// postgres postgres://user:password@host:5432/db?params +var mysqlDSN = regexp.MustCompile(`^([^:/@]+):([^@]*)@`) + +// redactDSN returns a DSN safe to log: everything but the password. +func redactDSN(dsn string) string { + if dsn == "" { + return "" + } + + // URL form, used by postgres and sqlserver. + if strings.Contains(dsn, "://") { + u, err := url.Parse(dsn) + if err != nil { + // Unparseable and possibly holding a password: say nothing about it. + return "[dsn]" + } + if u.User != nil { + if _, hasPassword := u.User.Password(); hasPassword { + u.User = url.UserPassword(u.User.Username(), "***") + } + } + return u.String() + } + + // user:password@tcp(...) form, used by mysql. + if m := mysqlDSN.FindStringSubmatch(dsn); m != nil { + return m[1] + ":***@" + dsn[len(m[0]):] + } + + // No credential recognised - a sqlite path, for instance. + return dsn +} diff --git a/common/database/dsn_test.go b/common/database/dsn_test.go new file mode 100644 index 00000000..e3c395d6 --- /dev/null +++ b/common/database/dsn_test.go @@ -0,0 +1,58 @@ +package database + +import ( + "strings" + "testing" +) + +// The startup line printed the DSN whole, so every deployment wrote its +// database password into its own logs - readable by anyone with the log file, a +// log shipper, or a screenshot of the terminal. +func TestRedactDSNKeepsThePasswordOut(t *testing.T) { + const secret = "s3cr3t-do-not-log" + + cases := map[string]string{ + "mysql": "goadmin:" + secret + "@tcp(db.example.com:3306)/go-admin?charset=utf8mb4&parseTime=True", + "postgres": "postgres://goadmin:" + secret + "@db.example.com:5432/go-admin?sslmode=disable", + "sqlserver": "sqlserver://goadmin:" + secret + "@db.example.com:1433?database=go-admin", + "empty-ish pwd": "goadmin:@tcp(db.example.com:3306)/go-admin", + } + + for name, dsn := range cases { + t.Run(name, func(t *testing.T) { + got := redactDSN(dsn) + if strings.Contains(got, secret) { + t.Fatalf("password survived redaction: %s", got) + } + // Still has to be useful: the host is what makes the line worth logging. + if !strings.Contains(got, "db.example.com") { + t.Errorf("host was lost, the line no longer says anything: %s", got) + } + if !strings.Contains(got, "goadmin") { + t.Errorf("username was lost: %s", got) + } + }) + } +} + +// sqlite has no credential to hide, and its path is the useful part. +func TestRedactDSNLeavesAPathAlone(t *testing.T) { + const path = "./go-admin-db.db" + if got := redactDSN(path); got != path { + t.Errorf("redactDSN(%q) = %q, want it unchanged", path, got) + } +} + +// An unparseable string might still hold a password, so it is not echoed. +func TestRedactDSNSaysNothingAboutWhatItCannotParse(t *testing.T) { + got := redactDSN("://not a url at all:hunter2@") + if strings.Contains(got, "hunter2") { + t.Fatalf("password survived: %s", got) + } +} + +func TestRedactDSNHandlesEmpty(t *testing.T) { + if got := redactDSN(""); got != "" { + t.Errorf("redactDSN(\"\") = %q", got) + } +} diff --git a/common/database/initialize.go b/common/database/initialize.go index d08a1ec8..09fb5253 100644 --- a/common/database/initialize.go +++ b/common/database/initialize.go @@ -28,7 +28,7 @@ func setupSimpleDatabase(host string, c *toolsConfig.Database) { if global.Driver == "" { global.Driver = c.Driver } - log.Infof("%s => %s", host, pkg.Green(c.Source)) + log.Infof("%s => %s", host, pkg.Green(redactDSN(c.Source))) registers := make([]toolsDB.ResolverConfigure, len(c.Registers)) for i := range c.Registers { registers[i] = toolsDB.NewResolverConfigure(