feat: enhance certificate model

This commit is contained in:
Fu Diwei
2025-02-06 16:01:46 +08:00
parent 5f5c835533
commit a41ee9c3ca
30 changed files with 545 additions and 273 deletions

View File

@@ -1,6 +1,7 @@
package applicant
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
@@ -110,14 +111,11 @@ func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderCon
Kid: sslProviderConfig.Config.GoogleTrustServices.EabKid,
HmacEncoded: sslProviderConfig.Config.GoogleTrustServices.EabHmacKey,
})
case sslProviderLetsEncrypt, sslProviderLetsEncryptStaging:
reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
default:
err = fmt.Errorf("unsupported ssl provider: %s", sslProviderConfig.Provider)
}
if err != nil {
return nil, err
}
@@ -129,7 +127,12 @@ func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderCon
return resp.Resource, nil
}
if err := repo.Save(sslProviderConfig.Provider, user.GetEmail(), user.getPrivateKeyPEM(), reg); err != nil {
if _, err := repo.Save(context.Background(), &domain.AcmeAccount{
CA: sslProviderConfig.Provider,
Email: user.GetEmail(),
Key: user.getPrivateKeyPEM(),
Resource: reg,
}); err != nil {
return nil, fmt.Errorf("failed to save registration: %w", err)
}

View File

@@ -26,6 +26,7 @@ type ApplyCertResult struct {
CertificateFullChain string
IssuerCertificate string
PrivateKey string
ACMEAccountUrl string
ACMECertUrl string
ACMECertStableUrl string
CSR string
@@ -46,8 +47,7 @@ type applicantOptions struct {
DnsPropagationTimeout int32
DnsTTL int32
DisableFollowCNAME bool
DisableARI bool
SkipBeforeExpiryDays int32
ReplacedARIAccount string
ReplacedARICertId string
}
@@ -67,8 +67,6 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) {
DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout,
DnsTTL: nodeConfig.DnsTTL,
DisableFollowCNAME: nodeConfig.DisableFollowCNAME,
DisableARI: nodeConfig.DisableARI,
SkipBeforeExpiryDays: nodeConfig.SkipBeforeExpiryDays,
}
accessRepo := repository.NewAccessRepository()
@@ -95,6 +93,7 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) {
lastCertX509, _ := certcrypto.ParsePEMCertificate([]byte(lastCertificate.Certificate))
if lastCertX509 != nil {
replacedARICertId, _ := certificate.MakeARICertID(lastCertX509)
options.ReplacedARIAccount = lastCertificate.ACMEAccountUrl
options.ReplacedARICertId = replacedARICertId
}
}
@@ -141,7 +140,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
// Create an ACME client config
config := lego.NewConfig(acmeUser)
config.CADirURL = sslProviderUrls[sslProviderConfig.Provider]
config.Certificate.KeyType = parseKeyAlgorithm(options.KeyAlgorithm)
config.Certificate.KeyType = parseKeyAlgorithm(domain.CertificateKeyAlgorithmType(options.KeyAlgorithm))
// Create an ACME client
client, err := lego.NewClient(config)
@@ -171,7 +170,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
Domains: options.Domains,
Bundle: true,
}
if !options.DisableARI {
if options.ReplacedARICertId != "" && options.ReplacedARIAccount != acmeUser.Registration.URI {
certRequest.ReplacesCertID = options.ReplacedARICertId
}
certResource, err := client.Certificate.Obtain(certRequest)
@@ -183,29 +182,30 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
CertificateFullChain: strings.TrimSpace(string(certResource.Certificate)),
IssuerCertificate: strings.TrimSpace(string(certResource.IssuerCertificate)),
PrivateKey: strings.TrimSpace(string(certResource.PrivateKey)),
ACMEAccountUrl: acmeUser.Registration.URI,
ACMECertUrl: certResource.CertURL,
ACMECertStableUrl: certResource.CertStableURL,
CSR: strings.TrimSpace(string(certResource.CSR)),
}, nil
}
func parseKeyAlgorithm(algo string) certcrypto.KeyType {
func parseKeyAlgorithm(algo domain.CertificateKeyAlgorithmType) certcrypto.KeyType {
switch algo {
case "RSA2048":
case domain.CertificateKeyAlgorithmTypeRSA2048:
return certcrypto.RSA2048
case "RSA3072":
case domain.CertificateKeyAlgorithmTypeRSA3072:
return certcrypto.RSA3072
case "RSA4096":
case domain.CertificateKeyAlgorithmTypeRSA4096:
return certcrypto.RSA4096
case "RSA8192":
case domain.CertificateKeyAlgorithmTypeRSA8192:
return certcrypto.RSA8192
case "EC256":
case domain.CertificateKeyAlgorithmTypeEC256:
return certcrypto.EC256
case "EC384":
case domain.CertificateKeyAlgorithmTypeEC384:
return certcrypto.EC384
default:
return certcrypto.RSA2048
}
return certcrypto.RSA2048
}
// TODO: 暂时使用代理模式以兼容之前版本代码,后续重新实现此处逻辑

View File

@@ -1,24 +1,76 @@
package domain
import "time"
import (
"crypto/x509"
"strings"
"time"
"github.com/usual2970/certimate/internal/pkg/utils/certs"
)
const CollectionNameCertificate = "certificate"
type Certificate struct {
Meta
Source CertificateSourceType `json:"source" db:"source"`
SubjectAltNames string `json:"subjectAltNames" db:"subjectAltNames"`
Certificate string `json:"certificate" db:"certificate"`
PrivateKey string `json:"privateKey" db:"privateKey"`
IssuerCertificate string `json:"issuerCertificate" db:"issuerCertificate"`
EffectAt time.Time `json:"effectAt" db:"effectAt"`
ExpireAt time.Time `json:"expireAt" db:"expireAt"`
ACMECertUrl string `json:"acmeCertUrl" db:"acmeCertUrl"`
ACMECertStableUrl string `json:"acmeCertStableUrl" db:"acmeCertStableUrl"`
WorkflowId string `json:"workflowId" db:"workflowId"`
WorkflowNodeId string `json:"workflowNodeId" db:"workflowNodeId"`
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputId"`
DeletedAt *time.Time `json:"deleted" db:"deleted"`
Source CertificateSourceType `json:"source" db:"source"`
SubjectAltNames string `json:"subjectAltNames" db:"subjectAltNames"`
SerialNumber string `json:"serialNumber" db:"serialNumber"`
Certificate string `json:"certificate" db:"certificate"`
PrivateKey string `json:"privateKey" db:"privateKey"`
Issuer string `json:"issuer" db:"issuer"`
IssuerCertificate string `json:"issuerCertificate" db:"issuerCertificate"`
KeyAlgorithm CertificateKeyAlgorithmType `json:"keyAlgorithm" db:"keyAlgorithm"`
EffectAt time.Time `json:"effectAt" db:"effectAt"`
ExpireAt time.Time `json:"expireAt" db:"expireAt"`
ACMEAccountUrl string `json:"acmeAccountUrl" db:"acmeAccountUrl"`
ACMECertUrl string `json:"acmeCertUrl" db:"acmeCertUrl"`
ACMECertStableUrl string `json:"acmeCertStableUrl" db:"acmeCertStableUrl"`
WorkflowId string `json:"workflowId" db:"workflowId"`
WorkflowNodeId string `json:"workflowNodeId" db:"workflowNodeId"`
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputId"`
DeletedAt *time.Time `json:"deleted" db:"deleted"`
}
func (c *Certificate) PopulateFromX509(certX509 *x509.Certificate) *Certificate {
c.SubjectAltNames = strings.Join(certX509.DNSNames, ";")
c.SerialNumber = strings.ToUpper(certX509.SerialNumber.Text(16))
c.Issuer = strings.Join(certX509.Issuer.Organization, ";")
c.EffectAt = certX509.NotBefore
c.ExpireAt = certX509.NotAfter
switch certX509.SignatureAlgorithm {
case x509.SHA256WithRSA, x509.SHA256WithRSAPSS:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeRSA2048
case x509.SHA384WithRSA, x509.SHA384WithRSAPSS:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeRSA3072
case x509.SHA512WithRSA, x509.SHA512WithRSAPSS:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeRSA4096
case x509.ECDSAWithSHA256:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeEC256
case x509.ECDSAWithSHA384:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeEC384
case x509.ECDSAWithSHA512:
c.KeyAlgorithm = CertificateKeyAlgorithmTypeEC512
default:
c.KeyAlgorithm = CertificateKeyAlgorithmType("")
}
return c
}
func (c *Certificate) PopulateFromPEM(certPEM, privkeyPEM string) *Certificate {
c.Certificate = certPEM
c.PrivateKey = privkeyPEM
_, issuerCertPEM, _ := certs.ExtractCertificatesFromPEM(certPEM)
c.IssuerCertificate = issuerCertPEM
certX509, _ := certs.ParseCertificateFromPEM(certPEM)
if certX509 != nil {
c.PopulateFromX509(certX509)
}
return c
}
type CertificateSourceType string
@@ -27,3 +79,15 @@ const (
CertificateSourceTypeWorkflow = CertificateSourceType("workflow")
CertificateSourceTypeUpload = CertificateSourceType("upload")
)
type CertificateKeyAlgorithmType string
const (
CertificateKeyAlgorithmTypeRSA2048 = CertificateKeyAlgorithmType("RSA2048")
CertificateKeyAlgorithmTypeRSA3072 = CertificateKeyAlgorithmType("RSA3072")
CertificateKeyAlgorithmTypeRSA4096 = CertificateKeyAlgorithmType("RSA4096")
CertificateKeyAlgorithmTypeRSA8192 = CertificateKeyAlgorithmType("RSA8192")
CertificateKeyAlgorithmTypeEC256 = CertificateKeyAlgorithmType("EC256")
CertificateKeyAlgorithmTypeEC384 = CertificateKeyAlgorithmType("EC384")
CertificateKeyAlgorithmTypeEC512 = CertificateKeyAlgorithmType("EC512")
)

View File

@@ -78,7 +78,7 @@ func (d *QiniuCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPe
// 获取域名信息
// REF: https://developer.qiniu.com/fusion/4246/the-domain-name
getDomainInfoResp, err := d.sdkClient.GetDomainInfo(domain)
getDomainInfoResp, err := d.sdkClient.GetDomainInfo(context.TODO(), domain)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.GetDomainInfo'")
}
@@ -88,14 +88,14 @@ func (d *QiniuCDNDeployer) Deploy(ctx context.Context, certPem string, privkeyPe
// 判断域名是否已启用 HTTPS。如果已启用修改域名证书否则启用 HTTPS
// REF: https://developer.qiniu.com/fusion/4246/the-domain-name
if getDomainInfoResp.Https != nil && getDomainInfoResp.Https.CertID != "" {
modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(domain, upres.CertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable)
modifyDomainHttpsConfResp, err := d.sdkClient.ModifyDomainHttpsConf(context.TODO(), domain, upres.CertId, getDomainInfoResp.Https.ForceHttps, getDomainInfoResp.Https.Http2Enable)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ModifyDomainHttpsConf'")
}
d.logger.Logt("已修改域名证书", modifyDomainHttpsConfResp)
} else {
enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(domain, upres.CertId, true, true)
enableDomainHttpsResp, err := d.sdkClient.EnableDomainHttps(context.TODO(), domain, upres.CertId, true, true)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.EnableDomainHttps'")
}

View File

@@ -60,7 +60,7 @@ func (u *QiniuSSLCertUploader) Upload(ctx context.Context, certPem string, privk
// 上传新证书
// REF: https://developer.qiniu.com/fusion/8593/interface-related-certificate
uploadSslCertResp, err := u.sdkClient.UploadSslCert(certName, certX509.Subject.CommonName, certPem, privkeyPem)
uploadSslCertResp, err := u.sdkClient.UploadSslCert(context.TODO(), certName, certX509.Subject.CommonName, certPem, privkeyPem)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.UploadSslCert'")
}

View File

@@ -26,15 +26,15 @@ func NewClient(mac *auth.Credentials) *Client {
return &Client{client: &client}
}
func (c *Client) GetDomainInfo(domain string) (*GetDomainInfoResponse, error) {
func (c *Client) GetDomainInfo(ctx context.Context, domain string) (*GetDomainInfoResponse, error) {
resp := new(GetDomainInfoResponse)
if err := c.client.Call(context.Background(), resp, http.MethodGet, c.urlf("domain/%s", domain), nil); err != nil {
if err := c.client.Call(ctx, resp, http.MethodGet, c.urlf("domain/%s", domain), nil); err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) ModifyDomainHttpsConf(domain, certId string, forceHttps, http2Enable bool) (*ModifyDomainHttpsConfResponse, error) {
func (c *Client) ModifyDomainHttpsConf(ctx context.Context, domain string, certId string, forceHttps bool, http2Enable bool) (*ModifyDomainHttpsConfResponse, error) {
req := &ModifyDomainHttpsConfRequest{
DomainInfoHttpsData: DomainInfoHttpsData{
CertID: certId,
@@ -43,13 +43,13 @@ func (c *Client) ModifyDomainHttpsConf(domain, certId string, forceHttps, http2E
},
}
resp := new(ModifyDomainHttpsConfResponse)
if err := c.client.CallWithJson(context.Background(), resp, http.MethodPut, c.urlf("domain/%s/httpsconf", domain), nil, req); err != nil {
if err := c.client.CallWithJson(ctx, resp, http.MethodPut, c.urlf("domain/%s/httpsconf", domain), nil, req); err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) EnableDomainHttps(domain, certId string, forceHttps, http2Enable bool) (*EnableDomainHttpsResponse, error) {
func (c *Client) EnableDomainHttps(ctx context.Context, domain string, certId string, forceHttps bool, http2Enable bool) (*EnableDomainHttpsResponse, error) {
req := &EnableDomainHttpsRequest{
DomainInfoHttpsData: DomainInfoHttpsData{
CertID: certId,
@@ -58,13 +58,13 @@ func (c *Client) EnableDomainHttps(domain, certId string, forceHttps, http2Enabl
},
}
resp := new(EnableDomainHttpsResponse)
if err := c.client.CallWithJson(context.Background(), resp, http.MethodPut, c.urlf("domain/%s/sslize", domain), nil, req); err != nil {
if err := c.client.CallWithJson(ctx, resp, http.MethodPut, c.urlf("domain/%s/sslize", domain), nil, req); err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) UploadSslCert(name, commonName, certificate, privateKey string) (*UploadSslCertResponse, error) {
func (c *Client) UploadSslCert(ctx context.Context, name string, commonName string, certificate string, privateKey string) (*UploadSslCertResponse, error) {
req := &UploadSslCertRequest{
Name: name,
CommonName: commonName,
@@ -72,7 +72,7 @@ func (c *Client) UploadSslCert(name, commonName, certificate, privateKey string)
PrivateKey: privateKey,
}
resp := new(UploadSslCertResponse)
if err := c.client.CallWithJson(context.Background(), resp, http.MethodPost, c.urlf("sslcert"), nil, req); err != nil {
if err := c.client.CallWithJson(ctx, resp, http.MethodPost, c.urlf("sslcert"), nil, req); err != nil {
return nil, err
}
return resp, nil

View File

@@ -1,6 +1,9 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/go-acme/lego/v4/registration"
@@ -48,18 +51,37 @@ func (r *AcmeAccountRepository) GetByCAAndEmail(ca, email string) (*domain.AcmeA
return r.castRecordToModel(record)
}
func (r *AcmeAccountRepository) Save(ca, email, key string, resource *registration.Resource) error {
func (r *AcmeAccountRepository) Save(ctx context.Context, acmeAccount *domain.AcmeAccount) (*domain.AcmeAccount, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameAcmeAccount)
if err != nil {
return err
return acmeAccount, err
}
record := core.NewRecord(collection)
record.Set("ca", ca)
record.Set("email", email)
record.Set("key", key)
record.Set("resource", resource)
return app.GetApp().Save(record)
var record *core.Record
if acmeAccount.Id == "" {
record = core.NewRecord(collection)
} else {
record, err = app.GetApp().FindRecordById(collection, acmeAccount.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return acmeAccount, domain.ErrRecordNotFound
}
return acmeAccount, err
}
}
record.Set("ca", acmeAccount.CA)
record.Set("email", acmeAccount.Email)
record.Set("key", acmeAccount.Key)
record.Set("resource", acmeAccount.Resource)
if err := app.GetApp().Save(record); err != nil {
return acmeAccount, err
}
acmeAccount.Id = record.Id
acmeAccount.CreatedAt = record.GetDateTime("created").Time()
acmeAccount.UpdatedAt = record.GetDateTime("updated").Time()
return acmeAccount, nil
}
func (r *AcmeAccountRepository) castRecordToModel(record *core.Record) (*domain.AcmeAccount, error) {

View File

@@ -79,6 +79,51 @@ func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflo
return r.castRecordToModel(records[0])
}
func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameCertificate)
if err != nil {
return certificate, err
}
var record *core.Record
if certificate.Id == "" {
record = core.NewRecord(collection)
} else {
record, err = app.GetApp().FindRecordById(collection, certificate.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return certificate, domain.ErrRecordNotFound
}
return certificate, err
}
}
record.Set("source", string(certificate.Source))
record.Set("subjectAltNames", certificate.SubjectAltNames)
record.Set("serialNumber", certificate.SerialNumber)
record.Set("certificate", certificate.Certificate)
record.Set("privateKey", certificate.PrivateKey)
record.Set("issuer", certificate.Issuer)
record.Set("issuerCertificate", certificate.IssuerCertificate)
record.Set("keyAlgorithm", string(certificate.KeyAlgorithm))
record.Set("effectAt", certificate.EffectAt)
record.Set("expireAt", certificate.ExpireAt)
record.Set("acmeAccountUrl", certificate.ACMEAccountUrl)
record.Set("acmeCertUrl", certificate.ACMECertUrl)
record.Set("acmeCertStableUrl", certificate.ACMECertStableUrl)
record.Set("workflowId", certificate.WorkflowId)
record.Set("workflowNodeId", certificate.WorkflowNodeId)
record.Set("workflowOutputId", certificate.WorkflowOutputId)
if err := app.GetApp().Save(record); err != nil {
return certificate, err
}
certificate.Id = record.Id
certificate.CreatedAt = record.GetDateTime("created").Time()
certificate.UpdatedAt = record.GetDateTime("updated").Time()
return certificate, nil
}
func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.Certificate, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
@@ -92,11 +137,15 @@ func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.
},
Source: domain.CertificateSourceType(record.GetString("source")),
SubjectAltNames: record.GetString("subjectAltNames"),
SerialNumber: record.GetString("serialNumber"),
Certificate: record.GetString("certificate"),
PrivateKey: record.GetString("privateKey"),
Issuer: record.GetString("issuer"),
IssuerCertificate: record.GetString("issuerCertificate"),
KeyAlgorithm: domain.CertificateKeyAlgorithmType(record.GetString("keyAlgorithm")),
EffectAt: record.GetDateTime("effectAt").Time(),
ExpireAt: record.GetDateTime("expireAt").Time(),
ACMEAccountUrl: record.GetString("acmeAccountUrl"),
ACMECertUrl: record.GetString("acmeCertUrl"),
ACMECertStableUrl: record.GetString("acmeCertStableUrl"),
WorkflowId: record.GetString("workflowId"),

View File

@@ -65,7 +65,7 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
if workflow.Id == "" {
record = core.NewRecord(collection)
} else {
record, err = app.GetApp().FindRecordById(domain.CollectionNameWorkflow, workflow.Id)
record, err = app.GetApp().FindRecordById(collection, workflow.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return workflow, domain.ErrRecordNotFound
@@ -85,7 +85,6 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
record.Set("lastRunId", workflow.LastRunId)
record.Set("lastRunStatus", string(workflow.LastRunStatus))
record.Set("lastRunTime", workflow.LastRunTime)
if err := app.GetApp().Save(record); err != nil {
return workflow, err
}
@@ -96,63 +95,63 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
return workflow, nil
}
func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) {
func (r *WorkflowRepository) SaveRun(ctx context.Context, run *domain.WorkflowRun) (*domain.WorkflowRun, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowRun)
if err != nil {
return workflowRun, err
return run, err
}
var workflowRunRecord *core.Record
if workflowRun.Id == "" {
workflowRunRecord = core.NewRecord(collection)
var runRecord *core.Record
if run.Id == "" {
runRecord = core.NewRecord(collection)
} else {
workflowRunRecord, err = app.GetApp().FindRecordById(domain.CollectionNameWorkflowRun, workflowRun.Id)
runRecord, err = app.GetApp().FindRecordById(collection, run.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return workflowRun, err
return run, err
}
workflowRunRecord = core.NewRecord(collection)
runRecord = core.NewRecord(collection)
}
}
err = app.GetApp().RunInTransaction(func(txApp core.App) error {
workflowRunRecord.Set("workflowId", workflowRun.WorkflowId)
workflowRunRecord.Set("trigger", string(workflowRun.Trigger))
workflowRunRecord.Set("status", string(workflowRun.Status))
workflowRunRecord.Set("startedAt", workflowRun.StartedAt)
workflowRunRecord.Set("endedAt", workflowRun.EndedAt)
workflowRunRecord.Set("logs", workflowRun.Logs)
workflowRunRecord.Set("error", workflowRun.Error)
err = txApp.Save(workflowRunRecord)
runRecord.Set("workflowId", run.WorkflowId)
runRecord.Set("trigger", string(run.Trigger))
runRecord.Set("status", string(run.Status))
runRecord.Set("startedAt", run.StartedAt)
runRecord.Set("endedAt", run.EndedAt)
runRecord.Set("logs", run.Logs)
runRecord.Set("error", run.Error)
err = txApp.Save(runRecord)
if err != nil {
return err
}
workflowRecord, err := txApp.FindRecordById(domain.CollectionNameWorkflow, workflowRun.WorkflowId)
workflowRecord, err := txApp.FindRecordById(domain.CollectionNameWorkflow, run.WorkflowId)
if err != nil {
return err
}
workflowRecord.IgnoreUnchangedFields(true)
workflowRecord.Set("lastRunId", workflowRunRecord.Id)
workflowRecord.Set("lastRunStatus", workflowRunRecord.GetString("status"))
workflowRecord.Set("lastRunTime", workflowRunRecord.GetString("startedAt"))
workflowRecord.Set("lastRunId", runRecord.Id)
workflowRecord.Set("lastRunStatus", runRecord.GetString("status"))
workflowRecord.Set("lastRunTime", runRecord.GetString("startedAt"))
err = txApp.Save(workflowRecord)
if err != nil {
return err
}
workflowRun.Id = workflowRunRecord.Id
workflowRun.CreatedAt = workflowRunRecord.GetDateTime("created").Time()
workflowRun.UpdatedAt = workflowRunRecord.GetDateTime("updated").Time()
run.Id = runRecord.Id
run.CreatedAt = runRecord.GetDateTime("created").Time()
run.UpdatedAt = runRecord.GetDateTime("updated").Time()
return nil
})
if err != nil {
return workflowRun, err
return run, err
}
return workflowRun, nil
return run, nil
}
func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Workflow, error) {

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
@@ -17,13 +18,13 @@ func NewWorkflowOutputRepository() *WorkflowOutputRepository {
return &WorkflowOutputRepository{}
}
func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error) {
func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error) {
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameWorkflowOutput,
"nodeId={:nodeId}",
"-created",
1, 0,
dbx.Params{"nodeId": nodeId},
dbx.Params{"nodeId": workflowNodeId},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -34,7 +35,61 @@ func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId strin
if len(records) == 0 {
return nil, domain.ErrRecordNotFound
}
record := records[0]
return r.castRecordToModel(records[0])
}
func (r *WorkflowOutputRepository) Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error) {
record, err := r.saveRecord(ctx, workflowOutput)
if err != nil {
return workflowOutput, err
}
workflowOutput.Id = record.Id
workflowOutput.CreatedAt = record.GetDateTime("created").Time()
workflowOutput.UpdatedAt = record.GetDateTime("updated").Time()
return workflowOutput, nil
}
func (r *WorkflowOutputRepository) SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) {
record, err := r.saveRecord(ctx, workflowOutput)
if err != nil {
return workflowOutput, err
} else {
workflowOutput.Id = record.Id
workflowOutput.CreatedAt = record.GetDateTime("created").Time()
workflowOutput.UpdatedAt = record.GetDateTime("updated").Time()
}
if certificate != nil {
certificate.WorkflowId = workflowOutput.WorkflowId
certificate.WorkflowNodeId = workflowOutput.NodeId
certificate.WorkflowOutputId = workflowOutput.Id
certificate, err := NewCertificateRepository().Save(ctx, certificate)
if err != nil {
return workflowOutput, err
}
// 写入证书 ID 到工作流输出结果中
for i, item := range workflowOutput.Outputs {
if item.Name == string(domain.WorkflowNodeIONameCertificate) {
workflowOutput.Outputs[i].Value = certificate.Id
break
}
}
record.Set("outputs", workflowOutput.Outputs)
if err := app.GetApp().Save(record); err != nil {
return workflowOutput, err
}
}
return workflowOutput, err
}
func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*domain.WorkflowOutput, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
}
node := &domain.WorkflowNode{}
if err := record.UnmarshalJSONField("node", node); err != nil {
@@ -46,7 +101,7 @@ func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId strin
return nil, errors.New("failed to unmarshal output")
}
rs := &domain.WorkflowOutput{
workflowOutput := &domain.WorkflowOutput{
Meta: domain.Meta{
Id: record.Id,
CreatedAt: record.GetDateTime("created").Time(),
@@ -58,25 +113,22 @@ func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId strin
Outputs: outputs,
Succeeded: record.GetBool("succeeded"),
}
return rs, nil
return workflowOutput, nil
}
// 保存节点输出
func (r *WorkflowOutputRepository) Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error {
var record *core.Record
var err error
func (r *WorkflowOutputRepository) saveRecord(ctx context.Context, output *domain.WorkflowOutput) (*core.Record, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowOutput)
if err != nil {
return nil, err
}
var record *core.Record
if output.Id == "" {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowOutput)
if err != nil {
return err
}
record = core.NewRecord(collection)
} else {
record, err = app.GetApp().FindRecordById(domain.CollectionNameWorkflowOutput, output.Id)
record, err = app.GetApp().FindRecordById(collection, output.Id)
if err != nil {
return err
return record, err
}
}
record.Set("workflowId", output.WorkflowId)
@@ -84,53 +136,9 @@ func (r *WorkflowOutputRepository) Save(ctx context.Context, output *domain.Work
record.Set("node", output.Node)
record.Set("outputs", output.Outputs)
record.Set("succeeded", output.Succeeded)
if err := app.GetApp().Save(record); err != nil {
return err
return record, err
}
if cb != nil && certificate != nil {
if err := cb(record.Id); err != nil {
return err
}
certCollection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameCertificate)
if err != nil {
return err
}
certRecord := core.NewRecord(certCollection)
certRecord.Set("source", string(certificate.Source))
certRecord.Set("subjectAltNames", certificate.SubjectAltNames)
certRecord.Set("certificate", certificate.Certificate)
certRecord.Set("privateKey", certificate.PrivateKey)
certRecord.Set("issuerCertificate", certificate.IssuerCertificate)
certRecord.Set("effectAt", certificate.EffectAt)
certRecord.Set("expireAt", certificate.ExpireAt)
certRecord.Set("acmeCertUrl", certificate.ACMECertUrl)
certRecord.Set("acmeCertStableUrl", certificate.ACMECertStableUrl)
certRecord.Set("workflowId", certificate.WorkflowId)
certRecord.Set("workflowNodeId", certificate.WorkflowNodeId)
certRecord.Set("workflowOutputId", certificate.WorkflowOutputId)
if err := app.GetApp().Save(certRecord); err != nil {
return err
}
// 更新 certificate
for i, item := range output.Outputs {
if item.Name == string(domain.WorkflowNodeIONameCertificate) {
output.Outputs[i].Value = certRecord.Id
break
}
}
record.Set("outputs", output.Outputs)
if err := app.GetApp().Save(record); err != nil {
return err
}
}
return nil
return record, err
}

View File

@@ -3,7 +3,6 @@ package nodeprocessor
import (
"context"
"fmt"
"strings"
"time"
"golang.org/x/exp/maps"
@@ -30,89 +29,79 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode {
}
}
// 申请节点根据申请类型执行不同的操作
func (a *applyNode) Run(ctx context.Context) error {
a.AddOutput(ctx, a.node.Name, "开始执行")
func (n *applyNode) Run(ctx context.Context) error {
n.AddOutput(ctx, n.node.Name, "开始执行")
// 查询上次执行结果
lastOutput, err := a.outputRepo.GetByNodeId(ctx, a.node.Id)
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
a.AddOutput(ctx, a.node.Name, "查询申请记录失败", err.Error())
n.AddOutput(ctx, n.node.Name, "查询申请记录失败", err.Error())
return err
}
// 检测是否可以跳过本次执行
if skippable, skipReason := a.checkCanSkip(ctx, lastOutput); skippable {
a.AddOutput(ctx, a.node.Name, skipReason)
if skippable, skipReason := n.checkCanSkip(ctx, lastOutput); skippable {
n.AddOutput(ctx, n.node.Name, skipReason)
return nil
}
// 初始化申请器
applicant, err := applicant.NewWithApplyNode(a.node)
applicant, err := applicant.NewWithApplyNode(n.node)
if err != nil {
a.AddOutput(ctx, a.node.Name, "获取申请对象失败", err.Error())
n.AddOutput(ctx, n.node.Name, "获取申请对象失败", err.Error())
return err
}
// 申请证书
applyResult, err := applicant.Apply()
if err != nil {
a.AddOutput(ctx, a.node.Name, "申请失败", err.Error())
n.AddOutput(ctx, n.node.Name, "申请失败", err.Error())
return err
}
a.AddOutput(ctx, a.node.Name, "申请成功")
n.AddOutput(ctx, n.node.Name, "申请成功")
// 解析证书并生成实体
certX509, err := certs.ParseCertificateFromPEM(applyResult.CertificateFullChain)
if err != nil {
a.AddOutput(ctx, a.node.Name, "解析证书失败", err.Error())
n.AddOutput(ctx, n.node.Name, "解析证书失败", err.Error())
return err
}
certificate := &domain.Certificate{
Source: domain.CertificateSourceTypeWorkflow,
SubjectAltNames: strings.Join(certX509.DNSNames, ";"),
Certificate: applyResult.CertificateFullChain,
PrivateKey: applyResult.PrivateKey,
IssuerCertificate: applyResult.IssuerCertificate,
ACMEAccountUrl: applyResult.ACMEAccountUrl,
ACMECertUrl: applyResult.ACMECertUrl,
ACMECertStableUrl: applyResult.ACMECertStableUrl,
EffectAt: certX509.NotBefore,
ExpireAt: certX509.NotAfter,
WorkflowId: getContextWorkflowId(ctx),
WorkflowNodeId: a.node.Id,
}
certificate.PopulateFromX509(certX509)
// 保存执行结果
// TODO: 先保持一个节点始终只有一个输出,后续增加版本控制
currentOutput := &domain.WorkflowOutput{
WorkflowId: getContextWorkflowId(ctx),
NodeId: a.node.Id,
Node: a.node,
NodeId: n.node.Id,
Node: n.node,
Succeeded: true,
Outputs: a.node.Outputs,
Outputs: n.node.Outputs,
}
if lastOutput != nil {
currentOutput.Id = lastOutput.Id
}
if err := a.outputRepo.Save(ctx, currentOutput, certificate, func(id string) error {
if certificate != nil {
certificate.WorkflowOutputId = id
}
return nil
}); err != nil {
a.AddOutput(ctx, a.node.Name, "保存申请记录失败", err.Error())
if _, err := n.outputRepo.SaveWithCertificate(ctx, currentOutput, certificate); err != nil {
n.AddOutput(ctx, n.node.Name, "保存申请记录失败", err.Error())
return err
}
a.AddOutput(ctx, a.node.Name, "保存申请记录成功")
n.AddOutput(ctx, n.node.Name, "保存申请记录成功")
return nil
}
func (a *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := a.node.GetConfigForApply()
currentNodeConfig := n.node.GetConfigForApply()
lastNodeConfig := lastOutput.Node.GetConfigForApply()
if currentNodeConfig.Domains != lastNodeConfig.Domains {
return false, "配置项变化:域名"
@@ -130,7 +119,7 @@ func (a *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
return false, "配置项变化:数字签名算法"
}
lastCertificate, _ := a.certRepo.GetByWorkflowNodeId(ctx, a.node.Id)
lastCertificate, _ := n.certRepo.GetByWorkflowNodeId(ctx, n.node.Id)
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt)
if lastCertificate != nil && expirationTime > renewalInterval {

View File

@@ -18,11 +18,9 @@ func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
}
}
// 条件节点没有任何操作
func (c *conditionNode) Run(ctx context.Context) error {
c.AddOutput(ctx,
c.node.Name,
"完成",
)
func (n *conditionNode) Run(ctx context.Context) error {
// 此类型节点不需要执行任何操作,直接返回
n.AddOutput(ctx, n.node.Name, "完成")
return nil
}

View File

@@ -27,81 +27,81 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode {
}
}
func (d *deployNode) Run(ctx context.Context) error {
d.AddOutput(ctx, d.node.Name, "开始执行")
func (n *deployNode) Run(ctx context.Context) error {
n.AddOutput(ctx, n.node.Name, "开始执行")
// 查询上次执行结果
lastOutput, err := d.outputRepo.GetByNodeId(ctx, d.node.Id)
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
d.AddOutput(ctx, d.node.Name, "查询部署记录失败", err.Error())
n.AddOutput(ctx, n.node.Name, "查询部署记录失败", err.Error())
return err
}
// 获取前序节点输出证书
previousNodeOutputCertificateSource := d.node.GetConfigForDeploy().Certificate
previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate
previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, "#")
if len(previousNodeOutputCertificateSourceSlice) != 2 {
d.AddOutput(ctx, d.node.Name, "证书来源配置错误", previousNodeOutputCertificateSource)
n.AddOutput(ctx, n.node.Name, "证书来源配置错误", previousNodeOutputCertificateSource)
return fmt.Errorf("证书来源配置错误: %s", previousNodeOutputCertificateSource)
}
certificate, err := d.certRepo.GetByWorkflowNodeId(ctx, previousNodeOutputCertificateSourceSlice[0])
certificate, err := n.certRepo.GetByWorkflowNodeId(ctx, previousNodeOutputCertificateSourceSlice[0])
if err != nil {
d.AddOutput(ctx, d.node.Name, "获取证书失败", err.Error())
n.AddOutput(ctx, n.node.Name, "获取证书失败", err.Error())
return err
}
// 检测是否可以跳过本次执行
if skippable, skipReason := d.checkCanSkip(ctx, lastOutput); skippable {
if skippable, skipReason := n.checkCanSkip(ctx, lastOutput); skippable {
if certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
d.AddOutput(ctx, d.node.Name, "已部署过且证书未更新")
n.AddOutput(ctx, n.node.Name, "已部署过且证书未更新")
} else {
d.AddOutput(ctx, d.node.Name, skipReason)
n.AddOutput(ctx, n.node.Name, skipReason)
}
return nil
}
// 初始化部署器
deploy, err := deployer.NewWithDeployNode(d.node, struct {
deploy, err := deployer.NewWithDeployNode(n.node, struct {
Certificate string
PrivateKey string
}{Certificate: certificate.Certificate, PrivateKey: certificate.PrivateKey})
if err != nil {
d.AddOutput(ctx, d.node.Name, "获取部署对象失败", err.Error())
n.AddOutput(ctx, n.node.Name, "获取部署对象失败", err.Error())
return err
}
// 部署证书
if err := deploy.Deploy(ctx); err != nil {
d.AddOutput(ctx, d.node.Name, "部署失败", err.Error())
n.AddOutput(ctx, n.node.Name, "部署失败", err.Error())
return err
}
d.AddOutput(ctx, d.node.Name, "部署成功")
n.AddOutput(ctx, n.node.Name, "部署成功")
// 保存执行结果
// TODO: 先保持一个节点始终只有一个输出,后续增加版本控制
currentOutput := &domain.WorkflowOutput{
Meta: domain.Meta{},
WorkflowId: getContextWorkflowId(ctx),
NodeId: d.node.Id,
Node: d.node,
NodeId: n.node.Id,
Node: n.node,
Succeeded: true,
}
if lastOutput != nil {
currentOutput.Id = lastOutput.Id
}
if err := d.outputRepo.Save(ctx, currentOutput, nil, nil); err != nil {
d.AddOutput(ctx, d.node.Name, "保存部署记录失败", err.Error())
if _, err := n.outputRepo.Save(ctx, currentOutput); err != nil {
n.AddOutput(ctx, n.node.Name, "保存部署记录失败", err.Error())
return err
}
d.AddOutput(ctx, d.node.Name, "保存部署记录成功")
n.AddOutput(ctx, n.node.Name, "保存部署记录成功")
return nil
}
func (d *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := d.node.GetConfigForDeploy()
currentNodeConfig := n.node.GetConfigForDeploy()
lastNodeConfig := lastOutput.Node.GetConfigForDeploy()
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
return false, "配置项变化:主机提供商授权"

View File

@@ -18,10 +18,9 @@ func NewExecuteFailureNode(node *domain.WorkflowNode) *executeFailureNode {
}
}
func (e *executeFailureNode) Run(ctx context.Context) error {
e.AddOutput(ctx,
e.node.Name,
"进入执行失败分支",
)
func (n *executeFailureNode) Run(ctx context.Context) error {
// 此类型节点不需要执行任何操作,直接返回
n.AddOutput(ctx, n.node.Name, "进入执行失败分支")
return nil
}

View File

@@ -18,10 +18,9 @@ func NewExecuteSuccessNode(node *domain.WorkflowNode) *executeSuccessNode {
}
}
func (e *executeSuccessNode) Run(ctx context.Context) error {
e.AddOutput(ctx,
e.node.Name,
"进入执行成功分支",
)
func (n *executeSuccessNode) Run(ctx context.Context) error {
// 此类型节点不需要执行任何操作,直接返回
n.AddOutput(ctx, n.node.Name, "进入执行成功分支")
return nil
}

View File

@@ -23,8 +23,9 @@ type certificateRepository interface {
}
type workflowOutputRepository interface {
GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error)
Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error
GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error)
Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error)
SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error)
}
type settingsRepository interface {

View File

@@ -18,9 +18,9 @@ func NewStartNode(node *domain.WorkflowNode) *startNode {
}
}
func (s *startNode) Run(ctx context.Context) error {
// 开始节点没有任何操作
s.AddOutput(ctx, s.node.Name, "完成")
func (n *startNode) Run(ctx context.Context) error {
// 此类型节点不需要执行任何操作,直接返回
n.AddOutput(ctx, n.node.Name, "完成")
return nil
}

View File

@@ -3,7 +3,6 @@ package nodeprocessor
import (
"context"
"errors"
"strings"
"time"
"github.com/usual2970/certimate/internal/domain"
@@ -28,43 +27,34 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
// Run 上传证书节点执行
// 包含上传证书的工作流,理论上应该手动执行,如果每天定时执行,也只是重新保存一下
func (n *uploadNode) Run(ctx context.Context) error {
n.AddOutput(ctx,
n.node.Name,
"进入上传证书节点",
)
n.AddOutput(ctx, n.node.Name, "进入上传证书节点")
config := n.node.GetConfigForUpload()
nodeConfig := n.node.GetConfigForUpload()
// 检查证书是否过期
// 如果证书过期,则直接返回错误
certX509, err := certs.ParseCertificateFromPEM(config.Certificate)
if err != nil {
n.AddOutput(ctx,
n.node.Name,
"解析证书失败",
)
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
n.AddOutput(ctx, n.node.Name, "查询申请记录失败", err.Error())
return err
}
// 检查证书是否过期
// 如果证书过期,则直接返回错误
certX509, err := certs.ParseCertificateFromPEM(nodeConfig.Certificate)
if err != nil {
n.AddOutput(ctx, n.node.Name, "解析证书失败")
return err
}
if time.Now().After(certX509.NotAfter) {
n.AddOutput(ctx,
n.node.Name,
"证书已过期",
)
n.AddOutput(ctx, n.node.Name, "证书已过期")
return errors.New("certificate is expired")
}
// 生成实体
certificate := &domain.Certificate{
Source: domain.CertificateSourceTypeUpload,
SubjectAltNames: strings.Join(certX509.DNSNames, ";"),
Certificate: config.Certificate,
PrivateKey: config.PrivateKey,
EffectAt: certX509.NotBefore,
ExpireAt: certX509.NotAfter,
WorkflowId: getContextWorkflowId(ctx),
WorkflowNodeId: n.node.Id,
Source: domain.CertificateSourceTypeUpload,
}
certificate.PopulateFromPEM(nodeConfig.Certificate, nodeConfig.PrivateKey)
// 保存执行结果
// TODO: 先保持一个节点始终只有一个输出,后续增加版本控制
@@ -75,23 +65,10 @@ func (n *uploadNode) Run(ctx context.Context) error {
Succeeded: true,
Outputs: n.node.Outputs,
}
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
n.AddOutput(ctx, n.node.Name, "查询上传记录失败", err.Error())
return err
}
if lastOutput != nil {
currentOutput.Id = lastOutput.Id
}
if err := n.outputRepo.Save(ctx, currentOutput, certificate, func(id string) error {
if certificate != nil {
certificate.WorkflowOutputId = id
}
return nil
}); err != nil {
if _, err := n.outputRepo.SaveWithCertificate(ctx, currentOutput, certificate); err != nil {
n.AddOutput(ctx, n.node.Name, "保存上传记录失败", err.Error())
return err
}