完成证书导入后台任务及上传回收,更新回归进度
This commit is contained in:
parent
09616e732d
commit
fce5a84dd4
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 业务范围
|
## 业务范围
|
||||||
|
|
||||||
证书申请、当前证书部署及手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。
|
证书申请、当前证书部署、证书导入及申请/部署手动重试先持久化任务,再交给后台工作线程执行。浏览器断线不取消已接收工作,前端通过任务接口确认最终结果。提交成功、证书保存成功、逐服务器推送成功、整体任务成功是不同层级,不能混用。
|
||||||
|
|
||||||
## 接口与权限
|
## 接口与权限
|
||||||
|
|
||||||
@ -14,6 +14,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 |
|
| POST /api/v1/domains/:id/issue | 提交申请及关联部署,无请求体 | 可见域名、证书管理、部署执行、DNS 凭据使用权限;202 |
|
||||||
| POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 |
|
| POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 |
|
||||||
|
| POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 |
|
||||||
| POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 |
|
| POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 |
|
||||||
| DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 |
|
| DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 |
|
||||||
| GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 |
|
| GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 |
|
||||||
@ -131,11 +132,26 @@ deployment-results 响应保留旧 items,新增 reports;每次尝试独立
|
|||||||
|
|
||||||
## 回归与未完成项
|
## 回归与未完成项
|
||||||
|
|
||||||
|
### 证书导入接线补充
|
||||||
|
|
||||||
|
- 上传使用 multipart/form-data:certificate、private_key 必填,issuer_bundle 可选;整个请求最多 20 MiB,包含表单开销,不是每个文件分别获得 20 MiB 配额。错误上传参数返回 400。
|
||||||
|
- 兼容 JSON 路径字段 certificate_path、private_key_path、issuer_bundle_path;前两项必填,第三项可省略。路径在实际导入时仍受原有读取校验约束,异步接收不代表路径有效,不放宽任意本地文件读取权限。
|
||||||
|
- 返回与申请相同的 202、accepted、task 和 Location;task_type 为 certificate_import。不返回同步证书路径,不允许收到 202 就显示“证书已导入”。缺少 task.id 应提示结果未知,不能自动重新提交。
|
||||||
|
- 前端每次打开导入表单生成一个 Idempotency-Key,响应断开后继续使用原键。409 且 replayed=true 表示原任务仍活动,应跟踪原 task.id;已完成任务重放返回 200。相同键不比较上传内容,不用于更换文件或启动新的导入。
|
||||||
|
- 在途同域名任务冲突、幂等重放、无任务仓储、队列满或服务正在关闭时,本次上传副本由提交路径回收;成功入队后所有权交给工作线程,正常结束、授权拒绝、取消、关停和可恢复异常均回收副本。客户端断开不影响已接收任务。JSON 指定的源文件不在回收范围内。
|
||||||
|
- 进程强制终止后的上传残留清理尚未完成实机验证,不将正常关停回收扩大为崩溃清理验收通过。
|
||||||
|
- 已执行导入的最终完整报告新增 certificate_imported: true,表示证书已经保存,不表示部署成功;false 时字段可省略。该标志与任务终态及报告同事务持久化,不含源文件路径和私钥材料。
|
||||||
|
- 部署失败或部分成功且证书已保存时,任务消息明确提示检查结果后重试部署,无需重新导入。导入任务不支持 POST /tasks/:id/retry,返回 409,不能重放已回收的上传文件;修复目标后使用已有证书部署入口。
|
||||||
|
- 前端刷新时读取最近尝试的完整报告恢复保存标志;任务查询失败只显示无法确认,不将上传失败、部署失败、取消和保存状态混为一谈。
|
||||||
|
|
||||||
|
导入验证场景:正常上传及请求结束后继续执行、排队取消、服务关停、队列拒绝、异常、无效幂等键、重复上传回收、保存后部署失败、部分成功、禁止重试丢失的上传文件,以及排队后的账户/权限/资源变化。
|
||||||
|
|
||||||
已加入自动用例:断线后继续执行、排队取消、取消收尾禁止重试、队列满、异常脱敏、读取和保存失败、跨任务尝试拒绝、终态事务回滚、已完成与部分成功不被取消意图覆盖、前端迟到响应和网络恢复。
|
已加入自动用例:断线后继续执行、排队取消、取消收尾禁止重试、队列满、异常脱敏、读取和保存失败、跨任务尝试拒绝、终态事务回滚、已完成与部分成功不被取消意图覆盖、前端迟到响应和网络恢复。
|
||||||
|
|
||||||
- [~] 当前 Linux 自动回归持续执行;竞争检测尚无可靠通过结果。
|
- [~] 当前 Linux 自动回归持续执行;竞争检测尚无可靠通过结果。
|
||||||
- [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归和前端 256 项通过;未部署。
|
- [x] 完整验证及恢复报告持久化与页面读取已实现,关联回归通过;导入接线后最新前端 260 项及生产构建通过,未部署。
|
||||||
- [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。
|
- [x] HTTP 异步完成审计、逐服务器结果与任务终态同事务,已覆盖三类保存失败整体回滚;其他工作流及三库实机复审仍待完成。
|
||||||
- [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。
|
- [x] 后台证书共用执行器新增五处故障回滚、每次重试报告、等待取消及无活动尝试收尾回归;应用、仓储、HTTP 三包原生 Linux 测试、全后端测试及 go vet 均通过,未正式部署。
|
||||||
- [ ] 导入、版本恢复、校验及手动续期等其他长操作尚未全部异步化。
|
- [x] 生产导入入口及前端任务跟踪已接入异步执行;专项回归通过,未正式部署。
|
||||||
|
- [ ] 版本恢复、校验及手动续期等其他长操作尚未全部异步化;当前历史版本恢复的真实实现仍拒绝恢复,不能用模拟工作流测试替代功能验收。
|
||||||
- [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。
|
- [ ] 正式构建部署后验证超过反代等待窗口的真实操作、断线查询、取消回滚和服务重启;本说明不替代生产验收。
|
||||||
|
|||||||
@ -196,6 +196,10 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
|||||||
result, err := trackedWorkflow.Import(ctx, request)
|
result, err := trackedWorkflow.Import(ctx, request)
|
||||||
return result.Certificate, result.Deployment, err
|
return result.Certificate, result.Deployment, err
|
||||||
},
|
},
|
||||||
|
ImportCertificateTask: func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
result, err := workflow.Import(ctx, request)
|
||||||
|
return result.Certificate, result.Deployment, err
|
||||||
|
},
|
||||||
DeployCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, deployment.Report, error) {
|
DeployCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
result, err := trackedWorkflow.DeployCurrent(ctx, domainID)
|
result, err := trackedWorkflow.DeployCurrent(ctx, domainID)
|
||||||
return result.Certificate, result.Deployment, err
|
return result.Certificate, result.Deployment, err
|
||||||
|
|||||||
@ -49,6 +49,8 @@ type Report struct {
|
|||||||
// 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。
|
// 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。
|
||||||
VerificationStatus string `json:"verification_status,omitempty"`
|
VerificationStatus string `json:"verification_status,omitempty"`
|
||||||
RecoveryStatus string `json:"recovery_status,omitempty"`
|
RecoveryStatus string `json:"recovery_status,omitempty"`
|
||||||
|
// 导入已保存与部署成功分开记录,不保存证书路径或私钥内容。
|
||||||
|
CertificateImported bool `json:"certificate_imported,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||||
)
|
)
|
||||||
@ -166,7 +167,7 @@ func (s *Server) importCertificateFiles(c *gin.Context) {
|
|||||||
if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) {
|
if !s.authorizeVisibleDomainPermissions(c, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.importCertificate == nil {
|
if s.importCertificate == nil && s.importCertificateTask == nil {
|
||||||
s.writeError(c, http.StatusNotImplemented, "证书导入接口尚未配置", errors.New("证书导入流程未配置"))
|
s.writeError(c, http.StatusNotImplemented, "证书导入接口尚未配置", errors.New("证书导入流程未配置"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -176,6 +177,16 @@ func (s *Server) importCertificateFiles(c *gin.Context) {
|
|||||||
s.writeError(c, http.StatusBadRequest, "证书上传参数无效", err)
|
s.writeError(c, http.StatusBadRequest, "证书上传参数无效", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.importCertificateTask != nil {
|
||||||
|
// 仅传递独立参数和文件所有权,后台不得引用请求上下文。
|
||||||
|
s.submitPreparedDomainTask(c, "certificate_import", &preparedCertificateTask{
|
||||||
|
operation: func(ctx context.Context, _ string) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
return s.importCertificateTask(ctx, request)
|
||||||
|
},
|
||||||
|
cleanup: cleanup,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
result, report, err := s.importCertificate(s.taskWorkflowContext(c), request)
|
result, report, err := s.importCertificate(s.taskWorkflowContext(c), request)
|
||||||
report = sanitizeDeploymentReport(report)
|
report = sanitizeDeploymentReport(report)
|
||||||
@ -206,6 +217,12 @@ func parseCertificateImportRequest(c *gin.Context) (certificate.ImportRequest, f
|
|||||||
cleanup := func() {}
|
cleanup := func() {}
|
||||||
contentType := c.GetHeader("Content-Type")
|
contentType := c.GetHeader("Content-Type")
|
||||||
if strings.HasPrefix(strings.ToLower(contentType), "multipart/form-data") {
|
if strings.HasPrefix(strings.ToLower(contentType), "multipart/form-data") {
|
||||||
|
// 表单解析产生的暂存文件不属于异步工作线程;独立复制后立即回收。
|
||||||
|
defer func() {
|
||||||
|
if c.Request.MultipartForm != nil {
|
||||||
|
_ = c.Request.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
}()
|
||||||
if err := c.Request.ParseMultipartForm(certificateUploadLimit); err != nil {
|
if err := c.Request.ParseMultipartForm(certificateUploadLimit); err != nil {
|
||||||
return certificate.ImportRequest{}, cleanup, fmt.Errorf("解析上传表单失败:%w", err)
|
return certificate.ImportRequest{}, cleanup, fmt.Errorf("解析上传表单失败:%w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
208
backend/internal/httpapi/certificate_import_async_test.go
Normal file
208
backend/internal/httpapi/certificate_import_async_test.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func importUploadRequest(t *testing.T, key string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
for _, name := range []string{"certificate", "private_key"} {
|
||||||
|
part, err := writer.CreateFormFile(name, name+".pem")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write([]byte(name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request.Header.Set("Idempotency-Key", key)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncImportUploadLifecycle(t *testing.T) {
|
||||||
|
for _, scenario := range []string{"success", "failure", "saved_failure", "partial", "panic", "cancel", "shutdown", "stopped", "full", "invalid_key", "replay"} {
|
||||||
|
t.Run(scenario, func(t *testing.T) {
|
||||||
|
temporary := t.TempDir()
|
||||||
|
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||||
|
t.Setenv(name, temporary)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||||
|
defer cleanup()
|
||||||
|
server.importCertificateTask = func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
for path, expected := range map[string]string{request.Certificate: "certificate", request.PrivateKey: "private_key"} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(data) != expected {
|
||||||
|
t.Errorf("上传文件被提前回收或内容变化:%v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("上传文件权限错误:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scenario == "panic" {
|
||||||
|
panic("private-import-error")
|
||||||
|
}
|
||||||
|
if scenario == "failure" {
|
||||||
|
return certificate.IssueResult{}, deployment.Report{}, errors.New("private-import-error")
|
||||||
|
}
|
||||||
|
result := certificate.IssueResult{DomainID: request.DomainID, Saved: certificate.SavedResource{
|
||||||
|
CertificatePath: "/private/cert.pem", FullChainPath: "/private/fullchain.pem", PrivateKeyPath: "/private/key.pem",
|
||||||
|
}}
|
||||||
|
if scenario == "saved_failure" || scenario == "partial" {
|
||||||
|
status := "failed"
|
||||||
|
if scenario == "partial" {
|
||||||
|
status = "partial"
|
||||||
|
}
|
||||||
|
return result, deployment.Report{Status: status}, errors.New("private-deploy-error")
|
||||||
|
}
|
||||||
|
return result, deployment.Report{Status: "success"}, nil
|
||||||
|
}
|
||||||
|
server.asyncTasks = make(map[string]bool)
|
||||||
|
server.taskSlots = make(chan struct{}, 1)
|
||||||
|
server.taskSlots <- struct{}{}
|
||||||
|
key := "import-lifecycle"
|
||||||
|
switch scenario {
|
||||||
|
case "stopped":
|
||||||
|
server.stopTaskWorkers()
|
||||||
|
case "full":
|
||||||
|
for index := range 32 {
|
||||||
|
server.asyncTasks[string(rune('a'+index))] = true
|
||||||
|
}
|
||||||
|
case "invalid_key":
|
||||||
|
key = strings.Repeat("x", 300)
|
||||||
|
}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := importUploadRequest(t, key)
|
||||||
|
requestCtx, cancelRequest := context.WithCancel(request.Context())
|
||||||
|
serveAdminHTTP(server, response, request.WithContext(requestCtx))
|
||||||
|
cancelRequest()
|
||||||
|
files, err := filepath.Glob(filepath.Join(temporary, "scert-upload-*"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scenario == "stopped" || scenario == "full" || scenario == "invalid_key" {
|
||||||
|
want := http.StatusServiceUnavailable
|
||||||
|
if scenario == "invalid_key" {
|
||||||
|
want = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
if response.Code != want || len(files) != 0 || calls.Load() != 0 {
|
||||||
|
t.Fatalf("拒绝提交仍保留上传文件或执行导入:%d %v %d", response.Code, files, calls.Load())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task := submittedTask(t, response)
|
||||||
|
if task.TaskType != "certificate_import" || len(files) != 2 || calls.Load() != 0 {
|
||||||
|
t.Fatalf("排队状态或上传文件所有权错误:%+v %v %d", task, files, calls.Load())
|
||||||
|
}
|
||||||
|
if scenario == "replay" {
|
||||||
|
replay := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, replay, importUploadRequest(t, key))
|
||||||
|
remaining, _ := filepath.Glob(filepath.Join(temporary, "scert-upload-*"))
|
||||||
|
if replay.Code != http.StatusConflict || len(remaining) != 2 {
|
||||||
|
t.Fatalf("重复提交未回收本次上传文件:%d %v", replay.Code, remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch scenario {
|
||||||
|
case "cancel":
|
||||||
|
cancelled := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, cancelled, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
|
||||||
|
if cancelled.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("取消排队导入失败:%d %s", cancelled.Code, cancelled.Body.String())
|
||||||
|
}
|
||||||
|
case "shutdown":
|
||||||
|
server.stopTaskWorkers()
|
||||||
|
default:
|
||||||
|
<-server.taskSlots
|
||||||
|
}
|
||||||
|
waitTaskWorkersForTest(t, server)
|
||||||
|
wantStatus := "success"
|
||||||
|
wantCalls := int32(1)
|
||||||
|
switch scenario {
|
||||||
|
case "failure", "saved_failure", "panic":
|
||||||
|
wantStatus = "failed"
|
||||||
|
case "partial":
|
||||||
|
wantStatus = "partial_success"
|
||||||
|
case "cancel", "shutdown":
|
||||||
|
wantStatus, wantCalls = "cancelled", 0
|
||||||
|
}
|
||||||
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
if err != nil || stored.Status != wantStatus || calls.Load() != wantCalls {
|
||||||
|
t.Fatalf("导入终态错误:%+v %v,执行次数 %d", stored, err, calls.Load())
|
||||||
|
}
|
||||||
|
for _, path := range files {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("上传文件未回收:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scenario == "saved_failure" || scenario == "partial" || scenario == "success" || scenario == "failure" {
|
||||||
|
reportResponse := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, reportResponse, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID+"/deployment-results", nil))
|
||||||
|
var payload struct {
|
||||||
|
Reports []struct {
|
||||||
|
Report deployment.Report `json:"report"`
|
||||||
|
} `json:"reports"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(reportResponse.Body.Bytes(), &payload); err != nil || len(payload.Reports) != 1 {
|
||||||
|
t.Fatalf("完整导入结果未持久化:%s %v", reportResponse.Body.String(), err)
|
||||||
|
}
|
||||||
|
if payload.Reports[0].Report.CertificateImported != (scenario != "failure") {
|
||||||
|
t.Fatalf("导入是否保存的状态丢失:%+v", payload)
|
||||||
|
}
|
||||||
|
if strings.Contains(reportResponse.Body.String(), "private-") || strings.Contains(reportResponse.Body.String(), "/private/") || strings.Contains(stored.Message, "private-") {
|
||||||
|
t.Fatal("公开结果泄露内部错误或证书路径")
|
||||||
|
}
|
||||||
|
if scenario == "saved_failure" || scenario == "partial" {
|
||||||
|
if !strings.Contains(stored.Message, "无需重新导入") {
|
||||||
|
t.Fatalf("已保存证书的失败结果不明确:%s", stored.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scenario == "failure" {
|
||||||
|
retry := httptest.NewRecorder()
|
||||||
|
serveAdminHTTP(server, retry, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/retry", nil))
|
||||||
|
if retry.Code != http.StatusConflict || calls.Load() != 1 {
|
||||||
|
t.Fatal("不得重放已回收上传文件的导入任务")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncImportRejectedSubmissionCleansFiles(t *testing.T) {
|
||||||
|
server, _, _, cleanup := newTaskAPITestServer(t, nil)
|
||||||
|
defer cleanup()
|
||||||
|
var cleaned atomic.Int32
|
||||||
|
server.tasks = nil
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(response)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
server.submitPreparedDomainTask(ctx, "certificate_import", &preparedCertificateTask{cleanup: func() { cleaned.Add(1) }})
|
||||||
|
if response.Code != http.StatusNotImplemented || cleaned.Load() != 1 {
|
||||||
|
t.Fatal("缺失任务仓储时必须回收上传文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,6 +60,8 @@ type Dependencies struct {
|
|||||||
IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
|
IssueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
|
||||||
RollbackSetupCertificate func(context.Context, certificate.SavedResource) error
|
RollbackSetupCertificate func(context.Context, certificate.SavedResource) error
|
||||||
ImportCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
ImportCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
||||||
|
// ImportCertificateTask 只执行导入流程,复用 HTTP 层已经创建的任务。
|
||||||
|
ImportCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
||||||
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
|
// DeployCertificateTask 执行已有任务,不得重复创建任务或尝试记录。
|
||||||
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
@ -99,6 +101,7 @@ type Server struct {
|
|||||||
issueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
|
issueSetupCertificate func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
|
||||||
rollbackSetupCertificate func(context.Context, certificate.SavedResource) error
|
rollbackSetupCertificate func(context.Context, certificate.SavedResource) error
|
||||||
importCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
importCertificate func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
||||||
|
importCertificateTask func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error)
|
||||||
deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
||||||
@ -167,6 +170,7 @@ func New(deps Dependencies) *Server {
|
|||||||
issueSetupCertificate: deps.IssueSetupCertificate,
|
issueSetupCertificate: deps.IssueSetupCertificate,
|
||||||
rollbackSetupCertificate: deps.RollbackSetupCertificate,
|
rollbackSetupCertificate: deps.RollbackSetupCertificate,
|
||||||
importCertificate: deps.ImportCertificate,
|
importCertificate: deps.ImportCertificate,
|
||||||
|
importCertificateTask: deps.ImportCertificateTask,
|
||||||
deployCertificate: deps.DeployCertificate,
|
deployCertificate: deps.DeployCertificate,
|
||||||
deployCertificateTask: deps.DeployCertificateTask,
|
deployCertificateTask: deps.DeployCertificateTask,
|
||||||
restoreVersion: deps.RestoreCertificateVersion,
|
restoreVersion: deps.RestoreCertificateVersion,
|
||||||
|
|||||||
@ -23,6 +23,21 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
|
func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
|
||||||
|
s.submitPreparedDomainTask(c, taskType, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparedCertificateTask struct {
|
||||||
|
operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepared *preparedCertificateTask) {
|
||||||
|
transferred := false
|
||||||
|
defer func() {
|
||||||
|
if !transferred && prepared != nil && prepared.cleanup != nil {
|
||||||
|
prepared.cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) {
|
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) {
|
||||||
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
|
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
|
||||||
return
|
return
|
||||||
@ -50,7 +65,7 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
|
|||||||
s.writeError(c, http.StatusServiceUnavailable, "证书任务权限服务尚未就绪", errors.New("项目授权服务未配置"))
|
s.writeError(c, http.StatusServiceUnavailable, "证书任务权限服务尚未就绪", errors.New("项目授权服务未配置"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if taskType == "certificate_issue" {
|
if taskType == "certificate_issue" || taskType == "certificate_import" {
|
||||||
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionCertificateManage); err != nil {
|
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionCertificateManage); err != nil {
|
||||||
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||||
s.writeError(c, http.StatusForbidden, "无权管理此项目的证书任务", nil)
|
s.writeError(c, http.StatusForbidden, "无权管理此项目的证书任务", nil)
|
||||||
@ -149,6 +164,14 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
|
|||||||
s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err)
|
s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if prepared != nil {
|
||||||
|
transferred = s.enqueueCertificateTask(c, task, attempt, taskType, prepared.operation,
|
||||||
|
taskType, "正在导入证书并部署", "证书导入和部署完成", prepared.cleanup)
|
||||||
|
if transferred {
|
||||||
|
writeAcceptedCertificateTask(c, task)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if taskType == "certificate_deploy" {
|
if taskType == "certificate_deploy" {
|
||||||
s.executeCertificateDeployTask(c, task, attempt, taskType)
|
s.executeCertificateDeployTask(c, task, attempt, taskType)
|
||||||
return
|
return
|
||||||
@ -157,11 +180,15 @@ func (s *Server) submitDomainTask(c *gin.Context, taskType string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) {
|
func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) {
|
||||||
s.enqueueCertificateTask(c, task, attempt, auditAction, s.issueCertificate, "certificate_issue", "正在申请证书并部署", "证书申请和部署完成")
|
if s.enqueueCertificateTask(c, task, attempt, auditAction, s.issueCertificate, "certificate_issue", "正在申请证书并部署", "证书申请和部署完成", nil) {
|
||||||
|
writeAcceptedCertificateTask(c, task)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) executeCertificateDeployTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) {
|
func (s *Server) executeCertificateDeployTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) {
|
||||||
s.enqueueCertificateTask(c, task, attempt, auditAction, s.deployCertificateTask, "certificate_deploy", "正在部署证书", "证书部署完成")
|
if s.enqueueCertificateTask(c, task, attempt, auditAction, s.deployCertificateTask, "certificate_deploy", "正在部署证书", "证书部署完成", nil) {
|
||||||
|
writeAcceptedCertificateTask(c, task)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) {
|
func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) {
|
||||||
@ -214,7 +241,11 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
if s.finishCancelledTask(task, attempt, executionCtx) {
|
if s.finishCancelledTask(task, attempt, executionCtx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, deploymentReport, issueErr := operation(executionCtx, task.DomainID)
|
certificateResult, deploymentReport, issueErr := operation(executionCtx, task.DomainID)
|
||||||
|
if task.TaskType == "certificate_import" {
|
||||||
|
deploymentReport.CertificateImported = issueErr == nil || (certificateResult.DomainID == task.DomainID &&
|
||||||
|
certificateResult.Saved.CertificatePath != "" && certificateResult.Saved.FullChainPath != "" && certificateResult.Saved.PrivateKeyPath != "")
|
||||||
|
}
|
||||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer finishCancel()
|
defer finishCancel()
|
||||||
deploymentReport = sanitizeDeploymentReport(deploymentReport)
|
deploymentReport = sanitizeDeploymentReport(deploymentReport)
|
||||||
@ -238,6 +269,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
} else if partial {
|
} else if partial {
|
||||||
status = "partial_success"
|
status = "partial_success"
|
||||||
}
|
}
|
||||||
|
if task.TaskType == "certificate_import" && issueErr != nil {
|
||||||
|
message = "证书导入失败"
|
||||||
|
if deploymentReport.CertificateImported {
|
||||||
|
message = "证书已导入,但部署未完成。请检查部署结果后重试部署,无需重新导入。"
|
||||||
|
}
|
||||||
|
}
|
||||||
s.taskMu.Lock()
|
s.taskMu.Lock()
|
||||||
defer s.taskMu.Unlock()
|
defer s.taskMu.Unlock()
|
||||||
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
|
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
|
||||||
|
|||||||
@ -26,6 +26,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep
|
|||||||
case "certificate_issue":
|
case "certificate_issue":
|
||||||
permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse)
|
permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse)
|
||||||
case "certificate_deploy":
|
case "certificate_deploy":
|
||||||
|
case "certificate_import":
|
||||||
|
permissions = append(permissions, domain.PermissionCertificateManage)
|
||||||
default:
|
default:
|
||||||
return repository.ErrProjectAccessDenied
|
return repository.ErrProjectAccessDenied
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||||
for _, taskType := range []string{"certificate_issue", "certificate_deploy"} {
|
for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import"} {
|
||||||
for _, scenario := range []string{
|
for _, scenario := range []string{
|
||||||
"unchanged", "disabled_user", "removed_member", "read_only_member",
|
"unchanged", "disabled_user", "removed_member", "read_only_member",
|
||||||
"moved_domain", "deleted_domain", "deployment_denied", "certificate_denied",
|
"moved_domain", "deleted_domain", "deployment_denied", "certificate_denied",
|
||||||
@ -32,6 +32,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
|||||||
server, tasks, audit, cleanup := newTaskAPITestServer(t, operation)
|
server, tasks, audit, cleanup := newTaskAPITestServer(t, operation)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
server.deployCertificateTask = operation
|
server.deployCertificateTask = operation
|
||||||
|
server.importCertificateTask = func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
|
||||||
|
return operation(ctx, request.DomainID)
|
||||||
|
}
|
||||||
server.asyncTasks = make(map[string]bool)
|
server.asyncTasks = make(map[string]bool)
|
||||||
server.taskSlots = make(chan struct{}, 1)
|
server.taskSlots = make(chan struct{}, 1)
|
||||||
server.taskSlots <- struct{}{}
|
server.taskSlots <- struct{}{}
|
||||||
@ -39,8 +42,12 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
|||||||
if taskType == "certificate_deploy" {
|
if taskType == "certificate_deploy" {
|
||||||
path = "/api/v1/domains/domain-1/deploy"
|
path = "/api/v1/domains/domain-1/deploy"
|
||||||
}
|
}
|
||||||
|
if taskType == "certificate_import" {
|
||||||
|
path = "/api/v1/domains/domain-1/certificate/import"
|
||||||
|
}
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path, nil))
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path,
|
||||||
|
strings.NewReader(`{"certificate_path":"/managed/cert.pem","private_key_path":"/managed/key.pem"}`)))
|
||||||
task := submittedTask(t, response)
|
task := submittedTask(t, response)
|
||||||
if calls.Load() != 0 {
|
if calls.Load() != 0 {
|
||||||
t.Fatal("占满执行槽后任务仍执行")
|
t.Fatal("占满执行槽后任务仍执行")
|
||||||
@ -95,7 +102,7 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
|||||||
<-server.taskSlots
|
<-server.taskSlots
|
||||||
waitTaskWorkersForTest(t, server)
|
waitTaskWorkersForTest(t, server)
|
||||||
want := "failed"
|
want := "failed"
|
||||||
if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") {
|
if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") || taskType == "certificate_import" && scenario == "dns_denied" {
|
||||||
want = "success"
|
want = "success"
|
||||||
}
|
}
|
||||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||||
|
|||||||
@ -15,13 +15,13 @@ import (
|
|||||||
|
|
||||||
// 持久化任务与尝试后才调用此入口;后台执行不得持有请求或响应对象。
|
// 持久化任务与尝试后才调用此入口;后台执行不得持有请求或响应对象。
|
||||||
// 进程中断由启动恢复标记失败,不自动重放可能已经签发或部署的外部操作。
|
// 进程中断由启动恢复标记失败,不自动重放可能已经签发或部署的外部操作。
|
||||||
func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) {
|
func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string, cleanup func()) bool {
|
||||||
s.taskMu.Lock()
|
s.taskMu.Lock()
|
||||||
if s.taskWorkersStopped || len(s.asyncTasks) >= 32 {
|
if s.taskWorkersStopped || len(s.asyncTasks) >= 32 {
|
||||||
s.taskMu.Unlock()
|
s.taskMu.Unlock()
|
||||||
s.finishTaskBeforeExecution(task, attempt, "failed", "服务正在停止或任务队列已满,任务未执行")
|
s.finishTaskBeforeExecution(task, attempt, "failed", "服务正在停止或任务队列已满,任务未执行")
|
||||||
s.writeError(c, http.StatusServiceUnavailable, "服务正在停止或任务队列已满,请稍后重试", nil)
|
s.writeError(c, http.StatusServiceUnavailable, "服务正在停止或任务队列已满,请稍后重试", nil)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), certificateTaskTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), certificateTaskTimeout)
|
||||||
if s.taskCancels == nil {
|
if s.taskCancels == nil {
|
||||||
@ -37,6 +37,9 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
|||||||
s.taskMu.Unlock()
|
s.taskMu.Unlock()
|
||||||
go func() {
|
go func() {
|
||||||
defer s.taskWorkers.Done()
|
defer s.taskWorkers.Done()
|
||||||
|
if cleanup != nil {
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
defer s.unregisterTaskCancel(task.ID)
|
defer s.unregisterTaskCancel(task.ID)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -53,6 +56,11 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
|||||||
}
|
}
|
||||||
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
||||||
}()
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传文件所有权完成转交后再写响应;客户端断开或响应异常不得回收工作线程的文件。
|
||||||
|
func writeAcceptedCertificateTask(c *gin.Context, task repository.Task) {
|
||||||
c.Header("Location", "/api/v1/tasks/"+task.ID)
|
c.Header("Location", "/api/v1/tasks/"+task.ID)
|
||||||
c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true})
|
c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
# Linux 生产流程测试记录
|
# Linux 生产流程测试记录
|
||||||
|
|
||||||
|
## 2026年10月2日19:34 证书导入异步自动回归(未部署)
|
||||||
|
|
||||||
|
- [x] 修改前确认 main 工作区干净,09616e7 基线推送返回已同步。第二台复用已核验主机指纹的密码 SSH 会话;隔离路径 /var/tmp/scert-async-20261002-1556/source/backend,不触碰正式安装或线上数据。
|
||||||
|
- [x] 最终后端输入 import-async-second-20261002.tar 两端 SHA256 一致:748f229134f568e7269d15d9cdd8412c5a62cec1f97ca65d28cda57e35f118c4。仅上传本批源码与原生 Go 测试,不是正式发布包。
|
||||||
|
- [!] import-after.log:首轮专项退出一,仅排队取消子场景因测试调用不存在的 POST /cancel 返回 404;修正为真实 DELETE /tasks/:id、202 响应,不放宽最终 cancelled 和零外部调用断言。
|
||||||
|
- [x] import-second.log:专项通过,IMPORT_SECOND_EXIT=0,6.967 秒。包含十一种上传生命周期场景、缺少任务仓储回收、导入新增十二个实时授权场景,以及原有申请/部署/重试授权回归。
|
||||||
|
- [x] import-full.log:全后端 go test -p 1 ./... -count=1 通过,IMPORT_FULL_EXIT=0;import-vet.log:go vet -p 1 ./... 通过,IMPORT_VET_EXIT=0。使用 nobody、GOMAXPROCS=1、GOMEMLIMIT=384MiB,测试自行创建独立空 SQLite 数据库,不将此记录当作三库完整实库验收。
|
||||||
|
- [x] import-repeat.log:新增 TestAsyncImport 专项重复二十次通过,IMPORT_REPEAT_EXIT=0,29.448 秒;普通重复测试不替代竞争检测。
|
||||||
|
- [x] 本机 .runtime/import-async-frontend-final-20261002.log:260 项通过,零失败、零跳过;.runtime/import-async-build-final-20261002.log:生产构建通过,退出码均为零。
|
||||||
|
- [x] 测试后第二台 Scert、Nginx、MySQL 均 active;磁盘余量约 863MB。未新增公网监听、未重启生产服务、未清理线上数据库。
|
||||||
|
- [!] 本批无正式部署或浏览器生产上传验收,无强制终止后的上传残留清理证明,无竞争检测通过结论。历史恢复、其他长操作及原有整体门禁继续未关闭。
|
||||||
|
|
||||||
|
|
||||||
## 2026年10月2日19:01 排队任务实时授权回归(未部署)
|
## 2026年10月2日19:01 排队任务实时授权回归(未部署)
|
||||||
|
|
||||||
- [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。
|
- [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
# Scert 整改任务执行清单
|
# Scert 整改任务执行清单
|
||||||
|
|
||||||
|
## 2026年10月2日19:34 证书导入异步化回归完成,等待复审
|
||||||
|
|
||||||
|
需求沿用已确认的 Linux 完整清单,先开发及自动回归,再统一部署,不处理 Windows。本批修改前已确认工作区干净并推送 09616e7 基线,无额外待提交文件。
|
||||||
|
|
||||||
|
- [x] 生产证书导入接入已有异步任务执行器,应用直接执行已有任务,不嵌套创建任务;返回 202 后继续导入和部署。
|
||||||
|
- [x] 上传副本转交工作线程,拒绝提交、排队取消、正常关停、执行异常及正常结束均回收文件;响应写出前完成所有权转交。JSON 源文件不在清理范围内。
|
||||||
|
- [x] 导入提交与执行前复核证书管理及部署权限;不要求 DNS 权限,导入任务的通用重试返回 409,不重放已回收上传文件。
|
||||||
|
- [x] 完整报告增加 certificate_imported 标志,与任务终态同事务持久化;保留已导入但部署失败或部分成功的真实状态,不存储源文件路径及私钥内容。
|
||||||
|
- [x] 前端展示任务编号并跟踪结果,不把 202 当作已导入;响应断开后沿用原幂等键找回任务,刷新可恢复保存标志,撤权清除缓存结果。
|
||||||
|
- [x] 原生 Linux 专项、全后端测试及 go vet 通过;新增导入专项重复二十次通过,耗时 29.448 秒。第一轮测试误用取消路由的失败记录保留,按真实 DELETE 和 202 契约修正后通过。
|
||||||
|
- [x] 最新前端 260 项全部通过、零跳过,生产构建通过;清单、Linux 测试记录和接口说明同步更新。本批不创建辅助验收脚本,不清线上数据库,不部署。
|
||||||
|
- [!] 进程强制终止后的上传残留清理、真实浏览器上传及生产流程尚未验收;普通重复回归不替代竞争检测。第二台磁盘剩余约 863MB,Scert、Nginx、MySQL 保持 active。
|
||||||
|
- [!] 历史版本恢复的真实实现目前明确拒绝恢复,与 P1-02 清单存在差异;该项继续保留,不能以模拟测试宣称完成。其余长操作、升级、通知重置、三库、备份恢复及完整生产门禁仍未关闭。
|
||||||
|
|
||||||
|
|
||||||
## 2026年10月2日19:01 排队任务执行前权限复核
|
## 2026年10月2日19:01 排队任务执行前权限复核
|
||||||
|
|
||||||
需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。
|
需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
# Scert 整改、合并与优化清单
|
# Scert 整改、合并与优化清单
|
||||||
|
|
||||||
|
## 2026年10月2日19:34 证书导入异步化开发进度
|
||||||
|
|
||||||
|
- [x] P2-03 子项:生产导入入口返回后台任务,复用任务队列、实时权限复核及事务收尾,不重复创建任务。上传文件在请求结束后保留到工作线程完成,并覆盖拒绝、取消、关停及异常回收。
|
||||||
|
- [x] P0-10/P1-04 配套:完整报告持久化证书已保存标志,区分导入失败与保存后部署失败;前端跟踪任务并支持响应断开后幂等找回,不提前显示成功,不重放已删除的上传材料。
|
||||||
|
- [x] 原生 Linux 专项、全后端测试、go vet 及导入专项二十次重复回归通过;前端 260 项和生产构建通过。代码尚未正式部署,子项状态为完成等待复审。
|
||||||
|
- [!] 历史恢复真实实现仍返回不支持恢复,与 P1-02 清单存在功能差异;该差异及校验等其他长操作继续保留。上传崩溃残留、升级、通知重置、三库、备份恢复与最终门禁均未据此关闭。
|
||||||
|
|
||||||
|
|
||||||
## 2026年10月2日19:01 排队执行前权限开发进度
|
## 2026年10月2日19:01 排队执行前权限开发进度
|
||||||
|
|
||||||
- [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。
|
- [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。
|
||||||
@ -1358,6 +1366,8 @@ frontend/src/views/Certificates/
|
|||||||
|
|
||||||
**状态:** `[~] 进行中`
|
**状态:** `[~] 进行中`
|
||||||
|
|
||||||
|
**2026年10月2日核对:** 当前文件存储只列出当前版本,真实恢复实现直接拒绝历史恢复,前端也未形成完整历史版本操作链;模拟工作流的恢复测试不代表此功能已经实现。保持本项未完成,不删除原始需求。
|
||||||
|
|
||||||
证书详情至少包含:
|
证书详情至少包含:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -1747,6 +1757,8 @@ cancelled 已取消
|
|||||||
|
|
||||||
**状态:** `[~] 进行中`
|
**状态:** `[~] 进行中`
|
||||||
|
|
||||||
|
**2026年10月2日19:34 子项进度:** 申请、当前证书部署、申请/部署重试及生产证书导入已接入 HTTP 异步任务;导入增加上传生命周期、执行前授权、幂等找回和保存结果持久化,自动回归通过待复审。校验、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。
|
||||||
|
|
||||||
以下操作不能长期阻塞 HTTP 请求:
|
以下操作不能长期阻塞 HTTP 请求:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export function useDashboard() {
|
|||||||
const importSaving = ref(false);
|
const importSaving = ref(false);
|
||||||
const importError = ref("");
|
const importError = ref("");
|
||||||
const importResult = ref(null);
|
const importResult = ref(null);
|
||||||
|
let importIdempotencyKey = "";
|
||||||
const importFiles = ref({
|
const importFiles = ref({
|
||||||
certificate: null,
|
certificate: null,
|
||||||
private_key: null,
|
private_key: null,
|
||||||
@ -839,6 +840,8 @@ export function useDashboard() {
|
|||||||
|
|
||||||
function openCertificateImport(item) {
|
function openCertificateImport(item) {
|
||||||
importDomain.value = item;
|
importDomain.value = item;
|
||||||
|
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
||||||
|
importIdempotencyKey = `import-${item.id}-${suffix}`;
|
||||||
importFiles.value = {
|
importFiles.value = {
|
||||||
certificate: null,
|
certificate: null,
|
||||||
private_key: null,
|
private_key: null,
|
||||||
@ -882,9 +885,25 @@ export function useDashboard() {
|
|||||||
}
|
}
|
||||||
const response = await fetchAdmin(
|
const response = await fetchAdmin(
|
||||||
`/api/v1/domains/${importDomain.value.id}/certificate/import`,
|
`/api/v1/domains/${importDomain.value.id}/certificate/import`,
|
||||||
{ method: "POST", body },
|
{ method: "POST", body, headers: { "Idempotency-Key": importIdempotencyKey } },
|
||||||
);
|
);
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 202 && !result.task?.id) {
|
||||||
|
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
|
||||||
|
}
|
||||||
|
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
|
||||||
|
(response.status === 202 || result.accepted || result.replayed)) {
|
||||||
|
importResult.value = result;
|
||||||
|
showCertificateImport.value = false;
|
||||||
|
notice.value = `导入任务已提交:${result.task.id},可在运行概览查看执行结果`;
|
||||||
|
deploymentResult.value = {
|
||||||
|
domain: importDomain.value.name, running: false, pending: true,
|
||||||
|
taskID: result.task.id, taskStatus: result.task.status,
|
||||||
|
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
|
||||||
|
};
|
||||||
|
await loadData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok && result.imported !== true) {
|
if (!response.ok && result.imported !== true) {
|
||||||
throw new Error(result.error || "导入证书失败");
|
throw new Error(result.error || "导入证书失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,6 +192,71 @@ test("certificateImport.submitsSelectedFilesAndResetsOnReopen", async (t) => {
|
|||||||
assert.ok(state.importError.value);
|
assert.ok(state.importError.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("导入异步接收只展示任务并等待结果,不提前宣布保存成功", async (t) => {
|
||||||
|
let imports = 0;
|
||||||
|
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||||
|
if (path.endsWith("/certificate/import")) {
|
||||||
|
imports++;
|
||||||
|
return Response.json({ accepted: true, task: { id: "import-task", status: "queued" } }, { status: 202 });
|
||||||
|
}
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
});
|
||||||
|
state.openCertificateImport({ id: "domain-import", name: "example.test" });
|
||||||
|
for (const kind of ["certificate", "private_key"]) {
|
||||||
|
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
|
||||||
|
}
|
||||||
|
await state.importCertificate();
|
||||||
|
assert.equal(state.deploymentResult.value.taskID, "import-task");
|
||||||
|
assert.equal(state.deploymentResult.value.pending, true);
|
||||||
|
assert.equal(state.deploymentResult.value.report, null);
|
||||||
|
assert.notEqual(state.deploymentResult.value.certificateImported, true);
|
||||||
|
assert.equal(state.showCertificateImport.value, false);
|
||||||
|
assert.match(state.notice.value, /导入任务已提交/);
|
||||||
|
assert.doesNotMatch(state.notice.value, /证书已导入/);
|
||||||
|
await state.importCertificate();
|
||||||
|
assert.equal(imports, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("导入响应断开后使用原幂等键找回任务,不重复执行导入", async (t) => {
|
||||||
|
const keys = [];
|
||||||
|
const { state } = await mountComposable(t, useDashboard, async (path, options) => {
|
||||||
|
if (path.endsWith("/certificate/import")) {
|
||||||
|
keys.push(options.headers["Idempotency-Key"]);
|
||||||
|
if (keys.length === 1) throw new Error("连接断开");
|
||||||
|
return Response.json({ replayed: true, task: { id: "existing-import", status: "running" } }, { status: 409 });
|
||||||
|
}
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
});
|
||||||
|
state.openCertificateImport({ id: "domain-import", name: "example.test" });
|
||||||
|
for (const kind of ["certificate", "private_key"]) {
|
||||||
|
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
|
||||||
|
}
|
||||||
|
await state.importCertificate();
|
||||||
|
assert.match(state.importError.value, /连接断开/);
|
||||||
|
await state.importCertificate();
|
||||||
|
assert.equal(keys.length, 2);
|
||||||
|
assert.match(keys[0], /^import-domain-import-/);
|
||||||
|
assert.equal(keys[0], keys[1]);
|
||||||
|
assert.equal(state.importError.value, "");
|
||||||
|
assert.equal(state.deploymentResult.value.taskID, "existing-import");
|
||||||
|
assert.equal(state.deploymentResult.value.pending, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("异步导入缺少任务编号时不能显示导入成功", async (t) => {
|
||||||
|
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||||
|
if (path.endsWith("/certificate/import")) return Response.json({ accepted: true }, { status: 202 });
|
||||||
|
return Response.json({ items: [] });
|
||||||
|
});
|
||||||
|
state.openCertificateImport({ id: "domain-import", name: "example.test" });
|
||||||
|
for (const kind of ["certificate", "private_key"]) {
|
||||||
|
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
|
||||||
|
}
|
||||||
|
await state.importCertificate();
|
||||||
|
assert.match(state.importError.value, /未返回任务编号/);
|
||||||
|
assert.equal(state.importResult.value, null);
|
||||||
|
assert.doesNotMatch(state.notice.value, /证书已导入/);
|
||||||
|
});
|
||||||
|
|
||||||
test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => {
|
test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => {
|
||||||
let imports = 0;
|
let imports = 0;
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
|||||||
taskStatus: snapshot.task.status,
|
taskStatus: snapshot.task.status,
|
||||||
taskMessage: snapshot.task.message || "",
|
taskMessage: snapshot.task.message || "",
|
||||||
report: snapshot.report,
|
report: snapshot.report,
|
||||||
|
certificateImported: snapshot.report?.certificate_imported === true,
|
||||||
queryError: "",
|
queryError: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -130,6 +131,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
|||||||
deploymentResult.value.queryError = message;
|
deploymentResult.value.queryError = message;
|
||||||
if (denied) {
|
if (denied) {
|
||||||
deploymentResult.value.report = null;
|
deploymentResult.value.report = null;
|
||||||
|
deploymentResult.value.certificateImported = false;
|
||||||
deploymentResult.value.taskMessage = "";
|
deploymentResult.value.taskMessage = "";
|
||||||
deploymentResult.value.taskStatus = "";
|
deploymentResult.value.taskStatus = "";
|
||||||
}
|
}
|
||||||
@ -149,6 +151,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
|||||||
taskDeploymentReport.value = null;
|
taskDeploymentReport.value = null;
|
||||||
if (deploymentResult.value) {
|
if (deploymentResult.value) {
|
||||||
deploymentResult.value.report = null;
|
deploymentResult.value.report = null;
|
||||||
|
deploymentResult.value.certificateImported = false;
|
||||||
deploymentResult.value.taskMessage = "";
|
deploymentResult.value.taskMessage = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,6 +91,30 @@ test("页面刷新查询完整报告并展示恢复状态", async (t) => {
|
|||||||
assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/);
|
assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("异步导入从持久化报告恢复保存状态,不因部署失败诱导重复导入", async (t) => {
|
||||||
|
const task = { ...initialTask, task_type: "certificate_import", status: "failed", message: "证书已导入,但部署未完成" };
|
||||||
|
const fetchTask = taskFetcher(() => task);
|
||||||
|
const { state } = await mountComposable(t, useDashboard, (path, options) => {
|
||||||
|
if (path.endsWith("/attempts")) {
|
||||||
|
return Response.json({ items: [{ id: "import-attempt", attempt_number: 1, status: "failed" }] });
|
||||||
|
}
|
||||||
|
if (path.endsWith("/deployment-results")) {
|
||||||
|
return Response.json({ items: [], reports: [{
|
||||||
|
task_id: task.id, attempt_id: "import-attempt", attempt_number: 1,
|
||||||
|
report: { status: "failed", results: [], certificate_imported: true },
|
||||||
|
}] });
|
||||||
|
}
|
||||||
|
return fetchTask(path, options);
|
||||||
|
});
|
||||||
|
state.deploymentResult.value = { taskID: task.id, pending: true };
|
||||||
|
await state.refreshTaskStatus();
|
||||||
|
assert.equal(state.deploymentResult.value.pending, false);
|
||||||
|
assert.equal(state.deploymentResult.value.certificateImported, true);
|
||||||
|
assert.equal(state.deploymentResult.value.taskStatus, "failed");
|
||||||
|
await state.openTaskDetails(task);
|
||||||
|
assert.equal(state.taskDeploymentReport.value.certificate_imported, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
|
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
|
||||||
let current = { ...initialTask };
|
let current = { ...initialTask };
|
||||||
const posts = [];
|
const posts = [];
|
||||||
@ -265,12 +289,14 @@ test("项目撤权后清除缓存的任务详情和部署结果", async (t) => {
|
|||||||
});
|
});
|
||||||
await state.openTaskDetails(current);
|
await state.openTaskDetails(current);
|
||||||
assert.equal(state.taskDeploymentReport.value.results.length, 1);
|
assert.equal(state.taskDeploymentReport.value.results.length, 1);
|
||||||
|
state.deploymentResult.value = { taskID: current.id, pending: true, certificateImported: true };
|
||||||
denied = true;
|
denied = true;
|
||||||
await state.refreshTaskStatus();
|
await state.refreshTaskStatus();
|
||||||
assert.equal(state.taskDetails.value, null);
|
assert.equal(state.taskDetails.value, null);
|
||||||
assert.equal(state.taskDeploymentReport.value, null);
|
assert.equal(state.taskDeploymentReport.value, null);
|
||||||
assert.deepEqual(state.taskAttempts.value, []);
|
assert.deepEqual(state.taskAttempts.value, []);
|
||||||
assert.match(state.taskDetailsError.value, /无权读取/);
|
assert.match(state.taskDetailsError.value, /无权读取/);
|
||||||
|
assert.equal(state.deploymentResult.value.certificateImported, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {
|
test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user