补齐旧 DNS 账户幂等导入

启动时登记旧 DNS 配置并保持用户修改,增加冲突保护和回归测试。
This commit is contained in:
xuans 2026-10-04 11:22:26 +08:00
parent 92b499979f
commit 920b318cf0
3 changed files with 133 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
@ -105,6 +106,25 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
return nil, fmt.Errorf("初始化超级管理员用户失败:%w", err)
}
}
if cfg.DNS.CredentialRef != "" && cfg.DNS.Provider != "" {
legacyProjectID := cfg.DNS.ProjectID
if legacyProjectID == "" {
legacyProjectID = domain.DefaultProjectID
}
legacyOwnerID := cfg.DNS.OwnerUserID
if legacyOwnerID == "" {
legacyOwnerID = "primary-super-admin"
}
legacyImporter := repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver)
if err := legacyImporter.ImportLegacyDNSAccount(ctx, domain.DNSAccount{
ID: "legacy-system-dns", Name: "旧系统 DNS 账户", ProjectID: legacyProjectID,
OwnerUserID: legacyOwnerID, Provider: cfg.DNS.Provider, CredentialRef: cfg.DNS.CredentialRef,
}); err != nil {
_ = db.Close()
_ = logger.Close()
return nil, fmt.Errorf("导入旧 DNS 账户失败:%w", err)
}
}
taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver)
transactionRepo := repository.NewSQLDeploymentTransactionRepository(db.DB, runtimeDatabaseConfig.Driver)
deploymentService := deployment.NewServiceWithJournal(deployment.SSHFactory{Secrets: secretStore}, transactionRepo)

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
@ -12,6 +13,8 @@ import (
var ErrDNSAccountConflict = errors.New("DNS 账户已变更,请刷新后重试")
var ErrLegacyDNSAccountConflict = errors.New("旧系统 DNS 账户标识已被其他账户占用")
// DNSAccountRepository 的公开读取不返回凭据引用;秘密读取只能使用独立的使用权限入口。
// 删除及运行租约将在域名绑定接入后提供,当前不提供无引用保护的删除方法。
type DNSAccountRepository interface {
@ -26,13 +29,75 @@ type DNSAccountRepository interface {
RecordDNSAccountVerification(context.Context, string, string, int64, bool) (domain.DNSAccount, error)
}
// DNSLegacyAccountImporter 只负责把旧配置登记为可追踪的账户元数据。
// 它不读取或复制秘密,也不把旧验证状态冒充为新账户已在线验证。
type DNSLegacyAccountImporter interface {
ImportLegacyDNSAccount(context.Context, domain.DNSAccount) error
}
var _ DNSAccountRepository = (*SQLProjectMemberRepository)(nil)
var _ DNSLegacyAccountImporter = (*SQLProjectMemberRepository)(nil)
const dnsAccountColumns = `dns_accounts.id, dns_accounts.name, dns_accounts.project_id,
dns_accounts.owner_user_id, dns_accounts.provider, dns_accounts.credential_ref,
dns_accounts.status, dns_accounts.verification_state, dns_accounts.revision,
dns_accounts.created_at, dns_accounts.updated_at`
// ImportLegacyDNSAccount 将旧的单账户配置幂等登记到独立账户表。
// 已存在同凭据引用时保持现有记录不变;同一固定 ID 被其他账户占用时拒绝启动迁移,避免覆盖用户修改。
func (r *SQLProjectMemberRepository) ImportLegacyDNSAccount(ctx context.Context, item domain.DNSAccount) error {
if err := r.ensureDB(); err != nil {
return err
}
item.Name = strings.TrimSpace(item.Name)
item.Status = domain.DNSAccountActive
item.VerificationState = domain.DNSAccountUnverified
item.Revision = 1
if err := item.Validate(); err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var existingID string
err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM dns_accounts WHERE credential_ref = ?`+r.resourceWriteLock()), item.CredentialRef).Scan(&existingID)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var existingRef string
err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT credential_ref FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), item.ID).Scan(&existingRef)
if err == nil {
if existingRef == item.CredentialRef {
return nil
}
return ErrLegacyDNSAccountConflict
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var projectID string
if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM projects WHERE id = ?`), item.ProjectID).Scan(&projectID); err != nil {
return fmt.Errorf("旧 DNS 账户所属项目不存在:%w", err)
}
now := time.Now().UTC()
item.CreatedAt, item.UpdatedAt = now, now
if _, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO dns_accounts
(id, name, project_id, owner_user_id, provider, credential_ref, status, verification_state, revision, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.ProjectID, item.OwnerUserID,
item.Provider, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.CreatedAt, item.UpdatedAt); err != nil {
return errors.New("登记旧 DNS 账户失败")
}
if err := writeDNSAccountAudit(ctx, tx, r.driver, item.OwnerUserID, item, "dns_account.import_legacy"); err != nil {
return err
}
return tx.Commit()
}
func scanDNSAccount(row scanner) (domain.DNSAccount, error) {
var item domain.DNSAccount
err := row.Scan(&item.ID, &item.Name, &item.ProjectID, &item.OwnerUserID, &item.Provider,

View File

@ -41,6 +41,54 @@ func TestDNSAccountRepository(t *testing.T) {
}
}
func TestImportLegacyDNSAccountIsIdempotentAndPreservesExistingChanges(t *testing.T) {
cfg := config.Default(t.TempDir()).Database
cfg.DSN = filepath.Join(t.TempDir(), "legacy-dns.db")
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
repo := NewSQLProjectMemberRepository(db.DB, "sqlite")
item := domain.DNSAccount{
ID: "legacy-system-dns", Name: "旧系统 DNS 账户", ProjectID: domain.DefaultProjectID,
OwnerUserID: "primary-super-admin", Provider: "aliyun", CredentialRef: "secret://dns/legacy",
}
if err := repo.ImportLegacyDNSAccount(t.Context(), item); err != nil {
t.Fatal(err)
}
var name, status, verification, reference string
var revision int
if err := db.DB.QueryRowContext(t.Context(), `SELECT name,status,verification_state,credential_ref,revision FROM dns_accounts WHERE id = ?`, item.ID).
Scan(&name, &status, &verification, &reference, &revision); err != nil {
t.Fatal(err)
}
if name != item.Name || status != domain.DNSAccountActive || verification != domain.DNSAccountUnverified || reference != item.CredentialRef || revision != 1 {
t.Fatalf("旧账户初次导入状态错误:%s %s %s %s %d", name, status, verification, reference, revision)
}
if _, err := db.DB.ExecContext(t.Context(), `UPDATE dns_accounts SET name = ?, status = ?, verification_state = ?, revision = ? WHERE id = ?`, "用户修改后的账户", domain.DNSAccountDisabled, domain.DNSAccountVerified, 8, item.ID); err != nil {
t.Fatal(err)
}
if err := repo.ImportLegacyDNSAccount(t.Context(), item); err != nil {
t.Fatal(err)
}
if err := db.DB.QueryRowContext(t.Context(), `SELECT name,status,verification_state,revision FROM dns_accounts WHERE id = ?`, item.ID).
Scan(&name, &status, &verification, &revision); err != nil {
t.Fatal(err)
}
if name != "用户修改后的账户" || status != domain.DNSAccountDisabled || verification != domain.DNSAccountVerified || revision != 8 {
t.Fatal("重复导入覆盖了现有账户修改")
}
conflict := item
conflict.CredentialRef = "secret://dns/other"
if err := repo.ImportLegacyDNSAccount(t.Context(), conflict); !errors.Is(err, ErrLegacyDNSAccountConflict) {
t.Fatalf("固定旧账户标识冲突未拒绝:%v", err)
}
}
func checkDNSAccountRepository(t *testing.T, db, admin *sql.DB, driver string) {
t.Helper()
repo := NewSQLProjectMemberRepository(db, driver)