169 lines
6.0 KiB
Go
169 lines
6.0 KiB
Go
package deployment
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/json"
|
||
"io"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||
)
|
||
|
||
// TestRemoteDeploymentIsolatedAcceptance 仅在显式配置后运行,不启动业务数据库、DNS 或 ACME。
|
||
// 每次使用全新隔离目录,保留验收产物;不执行目标机已有服务的校验、重载或重启。
|
||
func TestRemoteDeploymentIsolatedAcceptance(t *testing.T) {
|
||
if os.Getenv("SCERT_ACCEPTANCE_CONFIRM") != "isolated-directory" {
|
||
t.Skip("未启用隔离实机部署验收")
|
||
}
|
||
cfg, err := config.Load(os.Getenv("SCERT_ACCEPTANCE_CONFIG"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, name := range []string{"master.key", "secrets.json"} {
|
||
info, err := os.Lstat(filepath.Join(cfg.Paths.Config, name))
|
||
if err != nil || !info.Mode().IsRegular() {
|
||
t.Fatalf("现有凭据文件不可用:%s", name)
|
||
}
|
||
}
|
||
store, err := secretstore.Open(cfg.Paths.Config)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(os.Getenv("SCERT_ACCEPTANCE_TARGET"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var server Server
|
||
if err := json.Unmarshal(data, &server); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
root := "/etc/ssl/scert-acceptance-" + time.Now().UTC().Format("20060102-150405.000000000")
|
||
server.CertificatePath = path.Join(root, "cert.pem")
|
||
server.CertificateChainPath = path.Join(root, "fullchain.pem")
|
||
server.PrivateKeyPath = path.Join(root, "key.pem")
|
||
server.ValidationCommand = []string{"/usr/bin/true"}
|
||
server.ReloadCommand = []string{"/usr/bin/true"}
|
||
factory := SSHFactory{Secrets: store, Timeout: 10 * time.Second}
|
||
client, err := factory.Connect(t.Context(), server)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
exists, err := client.Exists(t.Context(), root)
|
||
_ = client.Close()
|
||
if err != nil || exists {
|
||
t.Fatalf("隔离目录必须尚不存在:exists=%t err=%v", exists, err)
|
||
}
|
||
t.Logf("隔离目标:%s,目录:%s;命令使用 true,不代表真实服务重载验收", server.Address, root)
|
||
service := NewService(factory)
|
||
original := testArtifact(t)
|
||
updated := testArtifact(t)
|
||
for index, artifact := range []Artifact{original, updated} {
|
||
report := service.Deploy(t.Context(), []Target{{Server: server}}, artifact)
|
||
if report.Status != "success" {
|
||
t.Fatalf("第 %d 次隔离部署失败:%+v", index+1, report)
|
||
}
|
||
t.Logf("隔离部署 %d 成功,事务:%s", index+1, report.Results[0].BackupID)
|
||
}
|
||
verify := func() {
|
||
t.Helper()
|
||
client, err := factory.Connect(t.Context(), server)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer client.Close()
|
||
adapter := client.(*sshClientAdapter)
|
||
for filename, expected := range map[string][]byte{"cert.pem": updated.Certificate, "fullchain.pem": updated.FullChain, "key.pem": updated.PrivateKey} {
|
||
file, err := adapter.sftp.Open(path.Join(root, filename))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actual, err := io.ReadAll(file)
|
||
_ = file.Close()
|
||
if err != nil || !bytes.Equal(actual, expected) {
|
||
t.Fatalf("文件内容不一致:%s %v", filename, err)
|
||
}
|
||
info, err := adapter.sftp.Stat(path.Join(root, filename))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
wantMode := os.FileMode(0o644)
|
||
if filename == "key.pem" {
|
||
wantMode = 0o600
|
||
}
|
||
if info.Mode().Perm() != wantMode {
|
||
t.Fatalf("文件权限错误:%s %o", filename, info.Mode().Perm())
|
||
}
|
||
if filename != "key.pem" {
|
||
t.Logf("内容与权限验证:%s SHA256=%x mode=%o", filename, sha256.Sum256(actual), wantMode)
|
||
}
|
||
}
|
||
files, err := adapter.sftp.ReadDir(root)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, file := range files {
|
||
if strings.Contains(file.Name(), ".scert-tmp-") {
|
||
t.Fatalf("残留临时文件:%s", file.Name())
|
||
}
|
||
if strings.HasSuffix(file.Name(), ".scert-lock") {
|
||
t.Fatalf("残留部署锁:%s", file.Name())
|
||
}
|
||
if strings.HasPrefix(file.Name(), "key.pem") && file.Mode().Perm() != 0o600 {
|
||
t.Fatal("私钥或私钥备份权限不安全")
|
||
}
|
||
}
|
||
}
|
||
verify()
|
||
// 真实连接竞争同一文件锁时必须拒绝部署,也不得释放另一事务的锁。
|
||
lockClient, err := factory.Connect(t.Context(), server)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
lockPath := server.PrivateKeyPath + ".scert-lock"
|
||
if err := lockClient.Lock(t.Context(), lockPath); err != nil {
|
||
_ = lockClient.Close()
|
||
t.Fatal(err)
|
||
}
|
||
lockedReport := service.Deploy(t.Context(), []Target{{Server: server}}, original)
|
||
lockExists, lockErr := lockClient.Exists(t.Context(), lockPath)
|
||
unlockErr := lockClient.Unlock(t.Context(), lockPath)
|
||
_ = lockClient.Close()
|
||
if lockedReport.Status != "failed" || !strings.Contains(lockedReport.Results[0].Message, "无法取得部署锁") || !lockExists || lockErr != nil || unlockErr != nil {
|
||
t.Fatalf("真实部署锁冲突未安全拒绝:%+v exists=%t check=%v unlock=%v", lockedReport, lockExists, lockErr, unlockErr)
|
||
}
|
||
t.Log("真实部署锁冲突已拒绝,其他事务的锁保持不变")
|
||
verify()
|
||
for _, scenario := range []string{"validation_failure", "reload_failure", "validation_timeout"} {
|
||
candidate := server
|
||
ctx := t.Context()
|
||
cancel := func() {}
|
||
switch scenario {
|
||
case "validation_failure":
|
||
candidate.ValidationCommand = []string{"/usr/bin/false"}
|
||
case "reload_failure":
|
||
candidate.ReloadCommand = []string{"/usr/bin/false"}
|
||
case "validation_timeout":
|
||
candidate.ValidationCommand = []string{"/usr/bin/sleep", "30"}
|
||
ctx, cancel = context.WithTimeout(ctx, 4*time.Second)
|
||
}
|
||
report := service.Deploy(ctx, []Target{{Server: candidate}}, original)
|
||
cancel()
|
||
if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || strings.Contains(report.Results[0].Message, "自动回滚失败") {
|
||
t.Fatalf("故障场景未安全回滚 %s:%+v", scenario, report)
|
||
}
|
||
if scenario == "validation_timeout" && !strings.Contains(report.Results[0].Message, "deadline exceeded") {
|
||
t.Fatalf("必须实际触发超时:%+v", report)
|
||
}
|
||
t.Logf("故障场景 %s:%s", scenario, report.Results[0].Message)
|
||
verify()
|
||
}
|
||
}
|