mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-25 19:36:07 +00:00
Merge pull request #880 from go-admin-team/fix/dsn-in-logs
ci🔧: migrate before deploying, roll back on failure — and stop logging the database password
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user