chore: harden production readiness gates and runbooks

This commit is contained in:
2026-02-09 11:27:23 +08:00
parent 05a0d07dbb
commit f1412a371d
15 changed files with 1001 additions and 322 deletions

View File

@@ -3,7 +3,7 @@ run-name: ${{ gitea.actor }} Build Application
on: [push]
jobs:
Build:
FrontendChecks:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
@@ -14,29 +14,74 @@ jobs:
with:
node-version: "20"
- name: Install dependencies and build frontend
- name: Install portal dependencies
run: |
cd frontend
npm config set registry https://npm.hub.ipao.vip
npm install
npm run build
cd frontend/portal
npm ci
- name: Portal lint (check only)
run: npm -C frontend/portal run lint
- name: Portal build
run: npm -C frontend/portal run build
- name: Install superadmin dependencies
run: |
cd frontend/superadmin
npm ci
- name: Superadmin lint (check only)
run: npm -C frontend/superadmin run lint
- name: Superadmin build
run: npm -C frontend/superadmin run build
BackendChecks:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.22"
- name: Configure Go proxy
run: |
go env -w GOPROXY=https://go.hub.ipao.vip,direct
go env -w GONOPROXY='git.ipao.vip'
go env -w GONOSUMDB='git.ipao.vip'
- name: Run backend tests
run: |
cd backend
go test ./...
- name: Build Go application
run: |
cd backend
mkdir -p build
go env -w GOPROXY=https://go.hub.ipao.vip,direct
go env -w GONOPROXY='git.ipao.vip'
go env -w GONOSUMDB='git.ipao.vip'
go mod tidy
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/app .
- name: API smoke (health/readiness)
run: |
cd backend
timeout 45s go run . serve > /tmp/quyun_backend.log 2>&1 &
APP_PID=$!
sleep 15
curl -f -sS http://127.0.0.1:18080/healthz > /tmp/healthz.out
curl -f -sS http://127.0.0.1:18080/readyz > /tmp/readyz.out
kill ${APP_PID}
DockerImage:
runs-on: ubuntu-latest
needs: [FrontendChecks, BackendChecks]
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Build final Docker image
run: |
docker login -u ${{ secrets.DOCKER_AF_USERNAME }} -p ${{ secrets.DOCKER_AF_PASSWORD }} docker-af.hub.ipao.vip
docker build --push -t docker-af.hub.ipao.vip/rogeecn/test:latest .
docker build --push -t docker-af.hub.ipao.vip/rogeecn/test:latest .

View File

@@ -2,6 +2,7 @@ package http
import (
"fmt"
"strings"
)
const DefaultPrefix = "Http"
@@ -60,3 +61,11 @@ func (h *Config) Address() string {
return fmt.Sprintf("%s:%d", h.Host, h.Port)
}
func (h *Config) HasTLS() bool {
if h == nil || h.TLS == nil {
return false
}
return strings.TrimSpace(h.TLS.Cert) != "" && strings.TrimSpace(h.TLS.Key) != ""
}

View File

@@ -2,6 +2,7 @@ package http
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
@@ -9,9 +10,13 @@ import (
"strings"
"time"
"quyun/v2/app/errorx"
"quyun/v2/providers/storage"
logrus "github.com/sirupsen/logrus"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/opt"
"go.uber.org/dig"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/compress"
@@ -22,8 +27,6 @@ import (
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/gofiber/fiber/v3/middleware/requestid"
"github.com/samber/lo"
"quyun/v2/app/errorx"
)
func DefaultProvider() container.ProviderContainer {
@@ -36,8 +39,10 @@ func DefaultProvider() container.ProviderContainer {
}
type Service struct {
conf *Config
Engine *fiber.App
conf *Config
Engine *fiber.App
healthCheck func(context.Context) error
readyCheck func(context.Context) error
}
var errTLSCertKeyRequired = errors.New("tls cert and key must be set")
@@ -98,7 +103,11 @@ func Provide(opts ...opt.Option) error {
return err
}
return container.Container.Provide(func() (*Service, error) {
return container.Container.Provide(func(params struct {
dig.In
DB *sql.DB `optional:"true"`
Storage *storage.Storage `optional:"true"`
}) (*Service, error) {
engine := fiber.New(fiber.Config{
StrictRouting: true,
CaseSensitive: true,
@@ -198,8 +207,14 @@ func Provide(opts ...opt.Option) error {
}))
}
engine.Get("/healthz", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) })
engine.Get("/readyz", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) })
service := &Service{
Engine: engine,
conf: &config,
}
service.healthCheck = service.buildHealthCheck()
service.readyCheck = service.buildReadyCheck(params.DB, params.Storage)
engine.Get("/healthz", service.handleHealthz)
engine.Get("/readyz", service.handleReadyz)
engine.Hooks().OnPostShutdown(func(err error) error {
if err != nil {
@@ -210,14 +225,72 @@ func Provide(opts ...opt.Option) error {
return nil
})
return &Service{
Engine: engine,
conf: &config,
}, nil
return service, nil
}, o.DiOptions()...)
}
// buildCORSConfig converts provider Cors config into fiber cors.Config
func (svc *Service) buildHealthCheck() func(context.Context) error {
return func(_ context.Context) error {
return nil
}
}
func (svc *Service) buildReadyCheck(db *sql.DB, store *storage.Storage) func(context.Context) error {
var dbPing func(context.Context) error
if db != nil {
dbPing = func(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
return db.PingContext(pingCtx)
}
}
return newReadyCheck(dbPing, store)
}
func newReadyCheck(dbPing func(context.Context) error, store *storage.Storage) func(context.Context) error {
return func(ctx context.Context) error {
if dbPing != nil {
if err := dbPing(ctx); err != nil {
return errorx.ErrServiceUnavailable.WithCause(err).WithMsg("database not ready")
}
}
if store != nil && store.Config != nil && strings.EqualFold(strings.TrimSpace(store.Config.Type), "s3") && store.Config.CheckOnBoot {
if strings.TrimSpace(store.Config.Endpoint) == "" || strings.TrimSpace(store.Config.Bucket) == "" {
return errorx.ErrServiceUnavailable.WithMsg("storage not ready")
}
}
return nil
}
}
func (svc *Service) handleHealthz(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if svc.healthCheck != nil {
if err := svc.healthCheck(ctx); err != nil {
return errorx.SendError(c, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
}
func (svc *Service) handleReadyz(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if svc.readyCheck != nil {
if err := svc.readyCheck(ctx); err != nil {
return errorx.SendError(c, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
}
func buildCORSConfig(c *Cors) *cors.Config {
if c == nil {
return nil

View File

@@ -0,0 +1,52 @@
package http
import (
"context"
"errors"
"testing"
"quyun/v2/providers/storage"
)
func TestNewReadyCheck(t *testing.T) {
t.Run("returns error when database ping fails", func(t *testing.T) {
checker := newReadyCheck(func(context.Context) error {
return errors.New("db down")
}, nil)
err := checker(context.Background())
if err == nil {
t.Fatalf("expected readiness error when db ping fails")
}
})
t.Run("returns error when s3 storage config is incomplete and check on boot enabled", func(t *testing.T) {
checker := newReadyCheck(nil, &storage.Storage{Config: &storage.Config{
Type: "s3",
CheckOnBoot: true,
Endpoint: "",
Bucket: "",
}})
err := checker(context.Background())
if err == nil {
t.Fatalf("expected readiness error when storage config is incomplete")
}
})
t.Run("returns nil when dependencies are ready", func(t *testing.T) {
checker := newReadyCheck(func(context.Context) error {
return nil
}, &storage.Storage{Config: &storage.Config{
Type: "s3",
CheckOnBoot: true,
Endpoint: "http://127.0.0.1:9000",
Bucket: "bucket",
}})
err := checker(context.Background())
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"strconv"
"strings"
"time"
"go.ipao.vip/atom/container"
@@ -92,6 +93,8 @@ func (config *Config) checkDefault() {
if config.SslMode == "" {
config.SslMode = "disable"
} else {
config.SslMode = strings.ToLower(strings.TrimSpace(config.SslMode))
}
if config.TimeZone == "" {
@@ -141,3 +144,9 @@ func (config *Config) DSN() string {
return base + extras
}
func (config *Config) IsTLSEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(config.SslMode))
return mode != "" && mode != "disable"
}

View File

@@ -3,12 +3,16 @@ package postgres
import (
"context"
"database/sql"
"fmt"
"math"
"time"
"quyun/v2/providers/app"
logrus "github.com/sirupsen/logrus"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/opt"
"go.uber.org/dig"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -22,7 +26,13 @@ func Provide(opts ...opt.Option) error {
return err
}
return container.Container.Provide(func() (*gorm.DB, *sql.DB, *Config, error) {
return container.Container.Provide(func(params struct {
dig.In
App *app.Config `optional:"true"`
}) (*gorm.DB, *sql.DB, *Config, error) {
if params.App != nil && params.App.IsReleaseMode() && !conf.IsTLSEnabled() {
return nil, nil, nil, fmt.Errorf("release mode requires Database.SslMode to enable TLS")
}
dbConfig := postgres.Config{DSN: conf.DSN()}
// 安全日志:不打印密码,仅输出关键连接信息