mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-24 23:10:13 +08:00
Merge pull request #926 from fudiwei/dev
This commit is contained in:
@@ -41,19 +41,20 @@ type Applicant interface {
|
||||
}
|
||||
|
||||
type ApplicantWithWorkflowNodeConfig struct {
|
||||
Node *domain.WorkflowNode
|
||||
Logger *slog.Logger
|
||||
WorkflowId string
|
||||
Node *domain.WorkflowNode
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, error) {
|
||||
if config.Node == nil {
|
||||
return nil, fmt.Errorf("node is nil")
|
||||
return nil, fmt.Errorf("the node is nil")
|
||||
}
|
||||
if config.Node.Type != domain.WorkflowNodeTypeApply {
|
||||
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeApply))
|
||||
if config.Node.Type != domain.WorkflowNodeTypeBizApply {
|
||||
return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizApply))
|
||||
}
|
||||
|
||||
nodeCfg := config.Node.GetConfigForApply()
|
||||
nodeCfg := config.Node.Data.Config.AsBizApply()
|
||||
options := &applicantProviderOptions{
|
||||
Domains: lo.Filter(strings.Split(nodeCfg.Domains, ";"), func(s string, _ int) bool { return s != "" }),
|
||||
ContactEmail: nodeCfg.ContactEmail,
|
||||
@@ -109,8 +110,8 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err
|
||||
options.CAProviderAccessConfig = sslProviderConfig.Config[options.CAProvider]
|
||||
}
|
||||
|
||||
certRepo := repository.NewCertificateRepository()
|
||||
lastCertificate, _ := certRepo.GetByWorkflowNodeId(context.Background(), config.Node.Id)
|
||||
certificateRepo := repository.NewCertificateRepository()
|
||||
lastCertificate, _ := certificateRepo.GetByWorkflowIdAndNodeId(context.Background(), config.WorkflowId, config.Node.Id)
|
||||
if lastCertificate != nil && !lastCertificate.ACMERenewed {
|
||||
newCertSan := slices.Clone(options.Domains)
|
||||
oldCertSan := strings.Split(lastCertificate.SubjectAltNames, ";")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,16 +18,6 @@ import (
|
||||
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
|
||||
)
|
||||
|
||||
type certificateRepository interface {
|
||||
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
|
||||
GetById(ctx context.Context, id string) (*domain.Certificate, error)
|
||||
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
|
||||
}
|
||||
|
||||
type settingsRepository interface {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
|
||||
type CertificateService struct {
|
||||
certificateRepo certificateRepository
|
||||
settingsRepo settingsRepository
|
||||
@@ -41,27 +32,8 @@ func NewCertificateService(certificateRepo certificateRepository, settingsRepo s
|
||||
|
||||
func (s *CertificateService) InitSchedule(ctx context.Context) error {
|
||||
// 每日清理过期证书
|
||||
app.GetScheduler().MustAdd("certificateExpiredCleanup", "0 0 * * *", func() {
|
||||
settings, err := s.settingsRepo.GetByName(ctx, "persistence")
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to get persistence settings", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
persistenceSettings, _ := settings.UnmarshalContentAsPersistence()
|
||||
if persistenceSettings != nil && persistenceSettings.ExpiredCertificatesMaxDaysRetention != 0 {
|
||||
ret, err := s.certificateRepo.DeleteWhere(
|
||||
context.Background(),
|
||||
dbx.NewExp(fmt.Sprintf("validityNotAfter<DATETIME('now', '-%d days')", persistenceSettings.ExpiredCertificatesMaxDaysRetention)),
|
||||
)
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to delete expired certificates", "err", err)
|
||||
}
|
||||
|
||||
if ret > 0 {
|
||||
app.GetLogger().Info(fmt.Sprintf("cleanup %d expired certificates", ret))
|
||||
}
|
||||
}
|
||||
app.GetScheduler().MustAdd("cleanupCertificateExpired", "0 0 * * *", func() {
|
||||
s.cleanupExpiredCertificates(context.Background())
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -218,3 +190,29 @@ func (s *CertificateService) ValidatePrivateKey(ctx context.Context, req *dtos.C
|
||||
IsValid: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *CertificateService) cleanupExpiredCertificates(ctx context.Context) error {
|
||||
settings, err := s.settingsRepo.GetByName(ctx, "persistence")
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to get persistence settings", slog.Any("error", err))
|
||||
return err
|
||||
}
|
||||
|
||||
persistenceSettings, _ := settings.UnmarshalContentAsPersistence()
|
||||
if persistenceSettings != nil && persistenceSettings.ExpiredCertificatesMaxDaysRetention != 0 {
|
||||
ret, err := s.certificateRepo.DeleteWhere(
|
||||
context.Background(),
|
||||
dbx.NewExp(fmt.Sprintf("validityNotAfter<DATETIME('now', '-%d days')", persistenceSettings.ExpiredCertificatesMaxDaysRetention)),
|
||||
)
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to delete expired certificates", slog.Any("error", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if ret > 0 {
|
||||
app.GetLogger().Info(fmt.Sprintf("cleanup %d expired certificates", ret))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type certificateRepository interface {
|
||||
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
|
||||
GetById(ctx context.Context, id string) (*domain.Certificate, error)
|
||||
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
|
||||
}
|
||||
|
||||
type settingsRepository interface {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
@@ -23,13 +23,13 @@ type DeployerWithWorkflowNodeConfig struct {
|
||||
|
||||
func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error) {
|
||||
if config.Node == nil {
|
||||
return nil, fmt.Errorf("node is nil")
|
||||
return nil, fmt.Errorf("the node is nil")
|
||||
}
|
||||
if config.Node.Type != domain.WorkflowNodeTypeDeploy {
|
||||
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeDeploy))
|
||||
if config.Node.Type != domain.WorkflowNodeTypeBizDeploy {
|
||||
return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizDeploy))
|
||||
}
|
||||
|
||||
nodeCfg := config.Node.GetConfigForDeploy()
|
||||
nodeCfg := config.Node.Data.Config.AsBizDeploy()
|
||||
options := &deployerProviderOptions{
|
||||
Provider: domain.DeploymentProviderType(nodeCfg.Provider),
|
||||
ProviderAccessConfig: make(map[string]any),
|
||||
|
||||
@@ -31,7 +31,6 @@ type Certificate struct {
|
||||
ACMERenewed bool `json:"acmeRenewed" db:"acmeRenewed"`
|
||||
WorkflowId string `json:"workflowId" db:"workflowRef"`
|
||||
WorkflowRunId string `json:"workflowRunId" db:"workflowRunRef"`
|
||||
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputRef"`
|
||||
WorkflowNodeId string `json:"workflowNodeId" db:"workflowNodeId"`
|
||||
DeletedAt *time.Time `json:"deleted" db:"deleted"`
|
||||
}
|
||||
@@ -111,7 +110,7 @@ func (c *Certificate) PopulateFromPEM(certPEM, privkeyPEM string) *Certificate {
|
||||
|
||||
certX509, _ := xcert.ParseCertificateFromPEM(certPEM)
|
||||
if certX509 != nil {
|
||||
c.PopulateFromX509(certX509)
|
||||
return c.PopulateFromX509(certX509)
|
||||
}
|
||||
|
||||
return c
|
||||
|
||||
@@ -7,7 +7,13 @@ type WorkflowStartRunReq struct {
|
||||
RunTrigger domain.WorkflowTriggerType `json:"trigger"`
|
||||
}
|
||||
|
||||
type WorkflowStartRunResp struct {
|
||||
RunId string `json:"runId"`
|
||||
}
|
||||
|
||||
type WorkflowCancelRunReq struct {
|
||||
WorkflowId string `json:"-"`
|
||||
RunId string `json:"-"`
|
||||
}
|
||||
|
||||
type WorkflowCancelRunResp struct{}
|
||||
|
||||
+165
-135
@@ -2,6 +2,7 @@ package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain/expr"
|
||||
@@ -17,30 +18,56 @@ type Workflow struct {
|
||||
Trigger WorkflowTriggerType `json:"trigger" db:"trigger"`
|
||||
TriggerCron string `json:"triggerCron" db:"triggerCron"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Content *WorkflowNode `json:"content" db:"content"`
|
||||
Draft *WorkflowNode `json:"draft" db:"draft"`
|
||||
GraphDraft *WorkflowGraph `json:"graphDraft" db:"graphDraft"`
|
||||
GraphContent *WorkflowGraph `json:"graphContent" db:"graphContent"`
|
||||
HasDraft bool `json:"hasDraft" db:"hasDraft"`
|
||||
HasContent bool `json:"hasContent" db:"hasContent"`
|
||||
LastRunId string `json:"lastRunId" db:"lastRunRef"`
|
||||
LastRunStatus WorkflowRunStatusType `json:"lastRunStatus" db:"lastRunStatus"`
|
||||
LastRunTime time.Time `json:"lastRunTime" db:"lastRunTime"`
|
||||
}
|
||||
|
||||
type WorkflowNodeType string
|
||||
type WorkflowGraph struct {
|
||||
Nodes []*WorkflowNode `json:"nodes"`
|
||||
}
|
||||
|
||||
const (
|
||||
WorkflowNodeTypeStart = WorkflowNodeType("start")
|
||||
WorkflowNodeTypeEnd = WorkflowNodeType("end")
|
||||
WorkflowNodeTypeApply = WorkflowNodeType("apply")
|
||||
WorkflowNodeTypeUpload = WorkflowNodeType("upload")
|
||||
WorkflowNodeTypeMonitor = WorkflowNodeType("monitor")
|
||||
WorkflowNodeTypeDeploy = WorkflowNodeType("deploy")
|
||||
WorkflowNodeTypeNotify = WorkflowNodeType("notify")
|
||||
WorkflowNodeTypeBranch = WorkflowNodeType("branch")
|
||||
WorkflowNodeTypeCondition = WorkflowNodeType("condition")
|
||||
WorkflowNodeTypeExecuteResultBranch = WorkflowNodeType("execute_result_branch")
|
||||
WorkflowNodeTypeExecuteSuccess = WorkflowNodeType("execute_success")
|
||||
WorkflowNodeTypeExecuteFailure = WorkflowNodeType("execute_failure")
|
||||
)
|
||||
func (g *WorkflowGraph) GetNodeById(nodeId string) (*WorkflowNode, bool) {
|
||||
return g.getNodeInBlocksById(g.Nodes, nodeId)
|
||||
}
|
||||
|
||||
func (g *WorkflowGraph) getNodeInBlocksById(blocks []*WorkflowNode, nodeId string) (*WorkflowNode, bool) {
|
||||
for _, node := range blocks {
|
||||
if node.Id == nodeId {
|
||||
return node, true
|
||||
}
|
||||
|
||||
if len(node.Blocks) > 0 {
|
||||
if found, ok := g.getNodeInBlocksById(node.Blocks, nodeId); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (g *WorkflowGraph) Verify() error {
|
||||
if len(g.Nodes) < 2 {
|
||||
return fmt.Errorf("invalid nodes length of graph")
|
||||
} else if g.Nodes[0].Type != WorkflowNodeTypeStart {
|
||||
return fmt.Errorf("the first node is not a start node")
|
||||
} else if g.Nodes[len(g.Nodes)-1].Type != WorkflowNodeTypeEnd {
|
||||
return fmt.Errorf("the last node is not an end node")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *WorkflowGraph) Clone() *WorkflowGraph {
|
||||
return &WorkflowGraph{
|
||||
Nodes: g.Nodes,
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowTriggerType string
|
||||
|
||||
@@ -50,31 +77,126 @@ const (
|
||||
)
|
||||
|
||||
type WorkflowNode struct {
|
||||
Id string `json:"id"`
|
||||
Type WorkflowNodeType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
|
||||
Config map[string]any `json:"config"`
|
||||
Inputs []WorkflowNodeIO `json:"inputs,omitempty"`
|
||||
Outputs []WorkflowNodeIO `json:"outputs,omitempty"`
|
||||
|
||||
Next *WorkflowNode `json:"next,omitempty"`
|
||||
Branches []WorkflowNode `json:"branches,omitempty"`
|
||||
|
||||
Validated bool `json:"validated"`
|
||||
Id string `json:"id"` // 节点 ID 只在该工作流中唯一,在全局中不保证唯一性
|
||||
Type WorkflowNodeType `json:"type"`
|
||||
Data WorkflowNodeData `json:"data"`
|
||||
Blocks []*WorkflowNode `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForApply struct {
|
||||
type WorkflowNodeType string
|
||||
|
||||
const (
|
||||
WorkflowNodeTypeStart = WorkflowNodeType("start")
|
||||
WorkflowNodeTypeEnd = WorkflowNodeType("end")
|
||||
WorkflowNodeTypeCondition = WorkflowNodeType("condition")
|
||||
WorkflowNodeTypeBranchBlock = WorkflowNodeType("branchBlock")
|
||||
WorkflowNodeTypeTryCatch = WorkflowNodeType("tryCatch")
|
||||
WorkflowNodeTypeTryBlock = WorkflowNodeType("tryBlock")
|
||||
WorkflowNodeTypeCatchBlock = WorkflowNodeType("catchBlock")
|
||||
WorkflowNodeTypeBizApply = WorkflowNodeType("bizApply")
|
||||
WorkflowNodeTypeBizUpload = WorkflowNodeType("bizUpload")
|
||||
WorkflowNodeTypeBizMonitor = WorkflowNodeType("bizMonitor")
|
||||
WorkflowNodeTypeBizDeploy = WorkflowNodeType("bizDeploy")
|
||||
WorkflowNodeTypeBizNotify = WorkflowNodeType("bizNotify")
|
||||
)
|
||||
|
||||
type WorkflowNodeData struct {
|
||||
Name string `json:"name"`
|
||||
Config WorkflowNodeConfig `json:"config"`
|
||||
}
|
||||
|
||||
type WorkflowNodeConfig map[string]any
|
||||
|
||||
func (c WorkflowNodeConfig) AsBizApply() WorkflowNodeConfigForBizApply {
|
||||
return WorkflowNodeConfigForBizApply{
|
||||
Domains: xmaps.GetString(c, "domains"),
|
||||
ContactEmail: xmaps.GetString(c, "contactEmail"),
|
||||
ChallengeType: xmaps.GetString(c, "challengeType"),
|
||||
Provider: xmaps.GetString(c, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(c, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"),
|
||||
KeyAlgorithm: xmaps.GetOrDefaultString(c, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)),
|
||||
CAProvider: xmaps.GetString(c, "caProvider"),
|
||||
CAProviderAccessId: xmaps.GetString(c, "caProviderAccessId"),
|
||||
CAProviderConfig: xmaps.GetKVMapAny(c, "caProviderConfig"),
|
||||
ACMEProfile: xmaps.GetString(c, "acmeProfile"),
|
||||
Nameservers: xmaps.GetString(c, "nameservers"),
|
||||
DnsPropagationWait: xmaps.GetInt32(c, "dnsPropagationWait"),
|
||||
DnsPropagationTimeout: xmaps.GetInt32(c, "dnsPropagationTimeout"),
|
||||
DnsTTL: xmaps.GetInt32(c, "dnsTTL"),
|
||||
DisableFollowCNAME: xmaps.GetBool(c, "disableFollowCNAME"),
|
||||
DisableARI: xmaps.GetBool(c, "disableARI"),
|
||||
SkipBeforeExpiryDays: xmaps.GetInt32(c, "skipBeforeExpiryDays"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c WorkflowNodeConfig) AsBizUpload() WorkflowNodeConfigForBizUpload {
|
||||
return WorkflowNodeConfigForBizUpload{
|
||||
Certificate: xmaps.GetString(c, "certificate"),
|
||||
PrivateKey: xmaps.GetString(c, "privateKey"),
|
||||
Domains: xmaps.GetString(c, "domains"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c WorkflowNodeConfig) AsBizMonitor() WorkflowNodeConfigForBizMonitor {
|
||||
host := xmaps.GetString(c, "host")
|
||||
return WorkflowNodeConfigForBizMonitor{
|
||||
Host: host,
|
||||
Port: xmaps.GetOrDefaultInt32(c, "port", 443),
|
||||
Domain: xmaps.GetOrDefaultString(c, "domain", host),
|
||||
RequestPath: xmaps.GetString(c, "path"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c WorkflowNodeConfig) AsBizDeploy() WorkflowNodeConfigForBizDeploy {
|
||||
return WorkflowNodeConfigForBizDeploy{
|
||||
CertificateOutputNodeId: xmaps.GetString(c, "certificateOutputNodeId"),
|
||||
Provider: xmaps.GetString(c, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(c, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"),
|
||||
SkipOnLastSucceeded: xmaps.GetBool(c, "skipOnLastSucceeded"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c WorkflowNodeConfig) AsBizNotify() WorkflowNodeConfigForBizNotify {
|
||||
return WorkflowNodeConfigForBizNotify{
|
||||
Provider: xmaps.GetString(c, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(c, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(c, "providerConfig"),
|
||||
Subject: xmaps.GetString(c, "subject"),
|
||||
Message: xmaps.GetString(c, "message"),
|
||||
SkipOnAllPrevSkipped: xmaps.GetBool(c, "skipOnAllPrevSkipped"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c WorkflowNodeConfig) AsBranchBlock() WorkflowNodeConfigForBranchBlock {
|
||||
expression := c["expression"]
|
||||
if expression == nil {
|
||||
return WorkflowNodeConfigForBranchBlock{}
|
||||
}
|
||||
|
||||
exprRaw, _ := json.Marshal(expression)
|
||||
expr, err := expr.UnmarshalExpr([]byte(exprRaw))
|
||||
if err != nil {
|
||||
return WorkflowNodeConfigForBranchBlock{}
|
||||
}
|
||||
|
||||
return WorkflowNodeConfigForBranchBlock{
|
||||
Expression: expr,
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForBizApply struct {
|
||||
Domains string `json:"domains"` // 域名列表,以半角分号分隔
|
||||
ContactEmail string `json:"contactEmail"` // 联系邮箱
|
||||
ChallengeType string `json:"challengeType"` // TODO: 验证方式。目前仅支持 dns-01
|
||||
ChallengeType string `json:"challengeType"` // 验证方式。目前仅支持 dns-01
|
||||
Provider string `json:"provider"` // DNS 提供商
|
||||
ProviderAccessId string `json:"providerAccessId"` // DNS 提供商授权记录 ID
|
||||
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // DNS 提供商额外配置
|
||||
KeyAlgorithm string `json:"keyAlgorithm,omitempty"` // 证书算法
|
||||
CAProvider string `json:"caProvider,omitempty"` // CA 提供商(零值时使用全局配置)
|
||||
CAProviderAccessId string `json:"caProviderAccessId,omitempty"` // CA 提供商授权记录 ID
|
||||
CAProviderConfig map[string]any `json:"caProviderConfig,omitempty"` // CA 提供商额外配置
|
||||
KeyAlgorithm string `json:"keyAlgorithm,omitempty"` // 证书算法
|
||||
ACMEProfile string `json:"acmeProfile,omitempty"` // ACME Profiles Extension
|
||||
Nameservers string `json:"nameservers,omitempty"` // DNS 服务器列表,以半角分号分隔
|
||||
DnsPropagationWait int32 `json:"dnsPropagationWait,omitempty"` // DNS 传播等待时间,等同于 lego 的 `--dns-propagation-wait` 参数
|
||||
@@ -82,31 +204,31 @@ type WorkflowNodeConfigForApply struct {
|
||||
DnsTTL int32 `json:"dnsTTL,omitempty"` // DNS 解析记录 TTL(零值时使用提供商的默认值)
|
||||
DisableFollowCNAME bool `json:"disableFollowCNAME,omitempty"` // 是否关闭 CNAME 跟随
|
||||
DisableARI bool `json:"disableARI,omitempty"` // 是否关闭 ARI
|
||||
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期(零值时默认值 30)
|
||||
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForUpload struct {
|
||||
type WorkflowNodeConfigForBizUpload struct {
|
||||
Certificate string `json:"certificate"` // 证书 PEM 内容
|
||||
PrivateKey string `json:"privateKey"` // 私钥 PEM 内容
|
||||
Domains string `json:"domains,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForMonitor struct {
|
||||
type WorkflowNodeConfigForBizMonitor struct {
|
||||
Host string `json:"host"` // 主机地址
|
||||
Port int32 `json:"port,omitempty"` // 端口(零值时默认值 443)
|
||||
Domain string `json:"domain,omitempty"` // 域名(零值时默认值 [Host])
|
||||
RequestPath string `json:"requestPath,omitempty"` // 请求路径
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForDeploy struct {
|
||||
Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate”
|
||||
Provider string `json:"provider"` // 主机提供商
|
||||
ProviderAccessId string `json:"providerAccessId,omitempty"` // 主机提供商授权记录 ID
|
||||
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 主机提供商额外配置
|
||||
SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过
|
||||
type WorkflowNodeConfigForBizDeploy struct {
|
||||
CertificateOutputNodeId string `json:"certificateOutputNodeId"` // 前序证书输出节点 ID
|
||||
Provider string `json:"provider"` // 主机提供商
|
||||
ProviderAccessId string `json:"providerAccessId,omitempty"` // 主机提供商授权记录 ID
|
||||
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 主机提供商额外配置
|
||||
SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForNotify struct {
|
||||
type WorkflowNodeConfigForBizNotify struct {
|
||||
Provider string `json:"provider"` // 通知提供商
|
||||
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
|
||||
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
|
||||
@@ -115,98 +237,6 @@ type WorkflowNodeConfigForNotify struct {
|
||||
SkipOnAllPrevSkipped bool `json:"skipOnAllPrevSkipped"` // 前序节点均已跳过时是否跳过
|
||||
}
|
||||
|
||||
type WorkflowNodeConfigForCondition struct {
|
||||
type WorkflowNodeConfigForBranchBlock struct {
|
||||
Expression expr.Expr `json:"expression"` // 条件表达式
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
|
||||
return WorkflowNodeConfigForApply{
|
||||
Domains: xmaps.GetString(n.Config, "domains"),
|
||||
ContactEmail: xmaps.GetString(n.Config, "contactEmail"),
|
||||
ChallengeType: xmaps.GetString(n.Config, "challengeType"),
|
||||
Provider: xmaps.GetString(n.Config, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"),
|
||||
CAProvider: xmaps.GetString(n.Config, "caProvider"),
|
||||
CAProviderAccessId: xmaps.GetString(n.Config, "caProviderAccessId"),
|
||||
CAProviderConfig: xmaps.GetKVMapAny(n.Config, "caProviderConfig"),
|
||||
KeyAlgorithm: xmaps.GetOrDefaultString(n.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)),
|
||||
ACMEProfile: xmaps.GetString(n.Config, "acmeProfile"),
|
||||
Nameservers: xmaps.GetString(n.Config, "nameservers"),
|
||||
DnsPropagationWait: xmaps.GetInt32(n.Config, "dnsPropagationWait"),
|
||||
DnsPropagationTimeout: xmaps.GetInt32(n.Config, "dnsPropagationTimeout"),
|
||||
DnsTTL: xmaps.GetInt32(n.Config, "dnsTTL"),
|
||||
DisableFollowCNAME: xmaps.GetBool(n.Config, "disableFollowCNAME"),
|
||||
DisableARI: xmaps.GetBool(n.Config, "disableARI"),
|
||||
SkipBeforeExpiryDays: xmaps.GetOrDefaultInt32(n.Config, "skipBeforeExpiryDays", 30),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload {
|
||||
return WorkflowNodeConfigForUpload{
|
||||
Certificate: xmaps.GetString(n.Config, "certificate"),
|
||||
PrivateKey: xmaps.GetString(n.Config, "privateKey"),
|
||||
Domains: xmaps.GetString(n.Config, "domains"),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForMonitor() WorkflowNodeConfigForMonitor {
|
||||
host := xmaps.GetString(n.Config, "host")
|
||||
return WorkflowNodeConfigForMonitor{
|
||||
Host: host,
|
||||
Port: xmaps.GetOrDefaultInt32(n.Config, "port", 443),
|
||||
Domain: xmaps.GetOrDefaultString(n.Config, "domain", host),
|
||||
RequestPath: xmaps.GetString(n.Config, "path"),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy {
|
||||
return WorkflowNodeConfigForDeploy{
|
||||
Certificate: xmaps.GetString(n.Config, "certificate"),
|
||||
Provider: xmaps.GetString(n.Config, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"),
|
||||
SkipOnLastSucceeded: xmaps.GetBool(n.Config, "skipOnLastSucceeded"),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify {
|
||||
return WorkflowNodeConfigForNotify{
|
||||
Provider: xmaps.GetString(n.Config, "provider"),
|
||||
ProviderAccessId: xmaps.GetString(n.Config, "providerAccessId"),
|
||||
ProviderConfig: xmaps.GetKVMapAny(n.Config, "providerConfig"),
|
||||
Subject: xmaps.GetString(n.Config, "subject"),
|
||||
Message: xmaps.GetString(n.Config, "message"),
|
||||
SkipOnAllPrevSkipped: xmaps.GetBool(n.Config, "skipOnAllPrevSkipped"),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *WorkflowNode) GetConfigForCondition() WorkflowNodeConfigForCondition {
|
||||
expression := n.Config["expression"]
|
||||
if expression == nil {
|
||||
return WorkflowNodeConfigForCondition{}
|
||||
}
|
||||
|
||||
exprRaw, _ := json.Marshal(expression)
|
||||
expr, err := expr.UnmarshalExpr([]byte(exprRaw))
|
||||
if err != nil {
|
||||
return WorkflowNodeConfigForCondition{}
|
||||
}
|
||||
|
||||
return WorkflowNodeConfigForCondition{
|
||||
Expression: expr,
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowNodeIO struct {
|
||||
Label string `json:"label"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Value any `json:"value"`
|
||||
ValueSelector WorkflowNodeIOValueSelector `json:"valueSelector"`
|
||||
}
|
||||
|
||||
type WorkflowNodeIOValueSelector = expr.ExprValueSelector
|
||||
|
||||
const WorkflowNodeIONameCertificate string = "certificate"
|
||||
|
||||
@@ -4,10 +4,17 @@ const CollectionNameWorkflowOutput = "workflow_output"
|
||||
|
||||
type WorkflowOutput struct {
|
||||
Meta
|
||||
WorkflowId string `json:"workflowId" db:"workflowRef"`
|
||||
RunId string `json:"runId" db:"runRef"`
|
||||
NodeId string `json:"nodeId" db:"nodeId"`
|
||||
Node *WorkflowNode `json:"node" db:"node"`
|
||||
Outputs []WorkflowNodeIO `json:"outputs" db:"outputs"`
|
||||
Succeeded bool `json:"succeeded" db:"succeeded"`
|
||||
WorkflowId string `json:"workflowId" db:"workflowRef"`
|
||||
RunId string `json:"runId" db:"runRef"`
|
||||
NodeId string `json:"nodeId" db:"nodeId"`
|
||||
NodeConfig WorkflowNodeConfig `json:"nodeConfig" db:"nodeConfig"`
|
||||
Outputs []*WorkflowOutputEntry `json:"outputs" db:"outputs"`
|
||||
Succeeded bool `json:"succeeded" db:"succeeded"`
|
||||
}
|
||||
|
||||
type WorkflowOutputEntry struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
ValueType string `json:"valueType"`
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ type WorkflowRun struct {
|
||||
Trigger WorkflowTriggerType `json:"trigger" db:"trigger"`
|
||||
StartedAt time.Time `json:"startedAt" db:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt" db:"endedAt"`
|
||||
Detail *WorkflowNode `json:"detail" db:"detail"`
|
||||
Graph *WorkflowGraph `json:"graph" db:"graph"`
|
||||
Error string `json:"error" db:"error"`
|
||||
}
|
||||
|
||||
type WorkflowRunStatusType string
|
||||
|
||||
const (
|
||||
WorkflowRunStatusTypePending WorkflowRunStatusType = "pending"
|
||||
WorkflowRunStatusTypeRunning WorkflowRunStatusType = "running"
|
||||
WorkflowRunStatusTypeSucceeded WorkflowRunStatusType = "succeeded"
|
||||
WorkflowRunStatusTypeFailed WorkflowRunStatusType = "failed"
|
||||
WorkflowRunStatusTypeCanceled WorkflowRunStatusType = "canceled"
|
||||
WorkflowRunStatusTypePending WorkflowRunStatusType = "pending"
|
||||
WorkflowRunStatusTypeProcessing WorkflowRunStatusType = "processing"
|
||||
WorkflowRunStatusTypeSucceeded WorkflowRunStatusType = "succeeded"
|
||||
WorkflowRunStatusTypeFailed WorkflowRunStatusType = "failed"
|
||||
WorkflowRunStatusTypeCanceled WorkflowRunStatusType = "canceled"
|
||||
)
|
||||
|
||||
@@ -23,13 +23,13 @@ type NotifierWithWorkflowNodeConfig struct {
|
||||
|
||||
func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error) {
|
||||
if config.Node == nil {
|
||||
return nil, fmt.Errorf("node is nil")
|
||||
return nil, fmt.Errorf("the node is nil")
|
||||
}
|
||||
if config.Node.Type != domain.WorkflowNodeTypeNotify {
|
||||
return nil, fmt.Errorf("node type is not '%s'", string(domain.WorkflowNodeTypeNotify))
|
||||
if config.Node.Type != domain.WorkflowNodeTypeBizNotify {
|
||||
return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizNotify))
|
||||
}
|
||||
|
||||
nodeCfg := config.Node.GetConfigForNotify()
|
||||
nodeCfg := config.Node.Data.Config.AsBizNotify()
|
||||
options := ¬ifierProviderOptions{
|
||||
Provider: domain.NotificationProviderType(nodeCfg.Provider),
|
||||
ProviderAccessConfig: make(map[string]any),
|
||||
|
||||
@@ -36,12 +36,12 @@ func (r *AccessRepository) GetById(ctx context.Context, id string) (*domain.Acce
|
||||
|
||||
func (r *AccessRepository) castRecordToModel(record *core.Record) (*domain.Access, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
config := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("config", &config); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("field 'config' is malformed")
|
||||
}
|
||||
|
||||
access := &domain.Access{
|
||||
|
||||
@@ -86,12 +86,12 @@ func (r *AcmeAccountRepository) Save(ctx context.Context, acmeAccount *domain.Ac
|
||||
|
||||
func (r *AcmeAccountRepository) castRecordToModel(record *core.Record) (*domain.AcmeAccount, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
resource := ®istration.Resource{}
|
||||
if err := record.UnmarshalJSONField("resource", resource); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("field 'resource' is malformed")
|
||||
}
|
||||
|
||||
acmeAccount := &domain.AcmeAccount{
|
||||
|
||||
@@ -58,12 +58,13 @@ func (r *CertificateRepository) GetById(ctx context.Context, id string) (*domain
|
||||
return r.castRecordToModel(record)
|
||||
}
|
||||
|
||||
func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error) {
|
||||
func (r *CertificateRepository) GetByWorkflowIdAndNodeId(ctx context.Context, workflowId string, workflowNodeId string) (*domain.Certificate, error) {
|
||||
records, err := app.GetApp().FindRecordsByFilter(
|
||||
domain.CollectionNameCertificate,
|
||||
"workflowNodeId={:workflowNodeId} && deleted=null",
|
||||
"workflowRef={:workflowId} && workflowNodeId={:workflowNodeId} && deleted=null",
|
||||
"-created",
|
||||
1, 0,
|
||||
dbx.Params{"workflowId": workflowId},
|
||||
dbx.Params{"workflowNodeId": workflowNodeId},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -132,7 +133,6 @@ func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Ce
|
||||
record.Set("acmeRenewed", certificate.ACMERenewed)
|
||||
record.Set("workflowRef", certificate.WorkflowId)
|
||||
record.Set("workflowRunRef", certificate.WorkflowRunId)
|
||||
record.Set("workflowOutputRef", certificate.WorkflowOutputId)
|
||||
record.Set("workflowNodeId", certificate.WorkflowNodeId)
|
||||
if err := app.GetApp().Save(record); err != nil {
|
||||
return certificate, err
|
||||
@@ -169,7 +169,7 @@ func (r *CertificateRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Ex
|
||||
|
||||
func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.Certificate, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
certificate := &domain.Certificate{
|
||||
@@ -194,7 +194,6 @@ func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.
|
||||
ACMERenewed: record.GetBool("acmeRenewed"),
|
||||
WorkflowId: record.GetString("workflowRef"),
|
||||
WorkflowRunId: record.GetString("workflowRunRef"),
|
||||
WorkflowOutputId: record.GetString("workflowOutputRef"),
|
||||
WorkflowNodeId: record.GetString("workflowNodeId"),
|
||||
}
|
||||
return certificate, nil
|
||||
|
||||
@@ -79,9 +79,10 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
|
||||
record.Set("trigger", string(workflow.Trigger))
|
||||
record.Set("triggerCron", workflow.TriggerCron)
|
||||
record.Set("enabled", workflow.Enabled)
|
||||
record.Set("content", workflow.Content)
|
||||
record.Set("draft", workflow.Draft)
|
||||
record.Set("graphDraft", workflow.GraphDraft)
|
||||
record.Set("graphContent", workflow.GraphContent)
|
||||
record.Set("hasDraft", workflow.HasDraft)
|
||||
record.Set("hasContent", workflow.HasContent)
|
||||
record.Set("lastRunRef", workflow.LastRunId)
|
||||
record.Set("lastRunStatus", string(workflow.LastRunStatus))
|
||||
record.Set("lastRunTime", workflow.LastRunTime)
|
||||
@@ -97,17 +98,17 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
|
||||
|
||||
func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Workflow, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
content := &domain.WorkflowNode{}
|
||||
if err := record.UnmarshalJSONField("content", content); err != nil {
|
||||
return nil, err
|
||||
graphDraft := &domain.WorkflowGraph{}
|
||||
if err := record.UnmarshalJSONField("graphDraft", graphDraft); err != nil {
|
||||
return nil, fmt.Errorf("field 'graphDraft' is malformed")
|
||||
}
|
||||
|
||||
draft := &domain.WorkflowNode{}
|
||||
if err := record.UnmarshalJSONField("draft", draft); err != nil {
|
||||
return nil, err
|
||||
graphContent := &domain.WorkflowGraph{}
|
||||
if err := record.UnmarshalJSONField("graphContent", graphContent); err != nil {
|
||||
return nil, fmt.Errorf("field 'graphContent' is malformed")
|
||||
}
|
||||
|
||||
workflow := &domain.Workflow{
|
||||
@@ -121,9 +122,10 @@ func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Wor
|
||||
Trigger: domain.WorkflowTriggerType(record.GetString("trigger")),
|
||||
TriggerCron: record.GetString("triggerCron"),
|
||||
Enabled: record.GetBool("enabled"),
|
||||
Content: content,
|
||||
Draft: draft,
|
||||
GraphDraft: graphDraft,
|
||||
GraphContent: graphContent,
|
||||
HasDraft: record.GetBool("hasDraft"),
|
||||
HasContent: record.GetBool("hasContent"),
|
||||
LastRunId: record.GetString("lastRunRef"),
|
||||
LastRunStatus: domain.WorkflowRunStatusType(record.GetString("lastRunStatus")),
|
||||
LastRunTime: record.GetDateTime("lastRunTime").Time(),
|
||||
|
||||
@@ -85,12 +85,12 @@ func (r *WorkflowLogRepository) Save(ctx context.Context, workflowLog *domain.Wo
|
||||
|
||||
func (r *WorkflowLogRepository) castRecordToModel(record *core.Record) (*domain.WorkflowLog, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
logdata := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("data", &logdata); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("field 'data' is malformed")
|
||||
}
|
||||
|
||||
workflowLog := &domain.WorkflowLog{
|
||||
|
||||
@@ -51,70 +51,19 @@ func (r *WorkflowOutputRepository) Save(ctx context.Context, workflowOutput *dom
|
||||
return workflowOutput, nil
|
||||
}
|
||||
|
||||
func (r *WorkflowOutputRepository) SaveWithCertificate(ctx context.Context, workflowOutput *domain.WorkflowOutput, certificate *domain.Certificate) (*domain.WorkflowOutput, error) {
|
||||
record, err := r.saveRecord(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 {
|
||||
panic("certificate is nil")
|
||||
} else {
|
||||
if certificate.WorkflowId != "" && certificate.WorkflowId != workflowOutput.WorkflowId {
|
||||
return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow #%s", certificate.Id, workflowOutput.WorkflowId)
|
||||
}
|
||||
if certificate.WorkflowRunId != "" && certificate.WorkflowRunId != workflowOutput.RunId {
|
||||
return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow run #%s", certificate.Id, workflowOutput.RunId)
|
||||
}
|
||||
if certificate.WorkflowNodeId != "" && certificate.WorkflowNodeId != workflowOutput.NodeId {
|
||||
return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow node #%s", certificate.Id, workflowOutput.NodeId)
|
||||
}
|
||||
if certificate.WorkflowOutputId != "" && certificate.WorkflowOutputId != workflowOutput.Id {
|
||||
return workflowOutput, fmt.Errorf("certificate #%s is not belong to workflow output #%s", certificate.Id, workflowOutput.Id)
|
||||
}
|
||||
|
||||
certificate.WorkflowId = workflowOutput.WorkflowId
|
||||
certificate.WorkflowRunId = workflowOutput.RunId
|
||||
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")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
node := &domain.WorkflowNode{}
|
||||
if err := record.UnmarshalJSONField("node", node); err != nil {
|
||||
return nil, err
|
||||
nodeConfig := make(domain.WorkflowNodeConfig)
|
||||
if err := record.UnmarshalJSONField("nodeConfig", &nodeConfig); err != nil {
|
||||
return nil, fmt.Errorf("field 'nodeConfig' is malformed")
|
||||
}
|
||||
|
||||
outputs := make([]domain.WorkflowNodeIO, 0)
|
||||
outputs := make([]*domain.WorkflowOutputEntry, 0)
|
||||
if err := record.UnmarshalJSONField("outputs", &outputs); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("field 'outputs' is malformed")
|
||||
}
|
||||
|
||||
workflowOutput := &domain.WorkflowOutput{
|
||||
@@ -126,7 +75,7 @@ func (r *WorkflowOutputRepository) castRecordToModel(record *core.Record) (*doma
|
||||
WorkflowId: record.GetString("workflowRef"),
|
||||
RunId: record.GetString("runRef"),
|
||||
NodeId: record.GetString("nodeId"),
|
||||
Node: node,
|
||||
NodeConfig: nodeConfig,
|
||||
Outputs: outputs,
|
||||
Succeeded: record.GetBool("succeeded"),
|
||||
}
|
||||
@@ -151,7 +100,7 @@ func (r *WorkflowOutputRepository) saveRecord(workflowOutput *domain.WorkflowOut
|
||||
record.Set("workflowRef", workflowOutput.WorkflowId)
|
||||
record.Set("runRef", workflowOutput.RunId)
|
||||
record.Set("nodeId", workflowOutput.NodeId)
|
||||
record.Set("node", workflowOutput.Node)
|
||||
record.Set("nodeConfig", workflowOutput.NodeConfig)
|
||||
record.Set("outputs", workflowOutput.Outputs)
|
||||
record.Set("succeeded", workflowOutput.Succeeded)
|
||||
if err := app.GetApp().Save(record); err != nil {
|
||||
|
||||
@@ -49,13 +49,50 @@ func (r *WorkflowRunRepository) Save(ctx context.Context, workflowRun *domain.Wo
|
||||
}
|
||||
}
|
||||
|
||||
record.Set("workflowRef", workflowRun.WorkflowId)
|
||||
record.Set("trigger", string(workflowRun.Trigger))
|
||||
record.Set("status", string(workflowRun.Status))
|
||||
record.Set("startedAt", workflowRun.StartedAt)
|
||||
record.Set("endedAt", workflowRun.EndedAt)
|
||||
record.Set("graph", workflowRun.Graph)
|
||||
record.Set("error", workflowRun.Error)
|
||||
err = app.GetApp().Save(record)
|
||||
if err != nil {
|
||||
return workflowRun, err
|
||||
}
|
||||
|
||||
workflowRun.Id = record.Id
|
||||
workflowRun.CreatedAt = record.GetDateTime("created").Time()
|
||||
workflowRun.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
return workflowRun, nil
|
||||
}
|
||||
|
||||
func (r *WorkflowRunRepository) SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) {
|
||||
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowRun)
|
||||
if err != nil {
|
||||
return workflowRun, err
|
||||
}
|
||||
|
||||
var record *core.Record
|
||||
if workflowRun.Id == "" {
|
||||
record = core.NewRecord(collection)
|
||||
} else {
|
||||
record, err = app.GetApp().FindRecordById(collection, workflowRun.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return workflowRun, err
|
||||
}
|
||||
record = core.NewRecord(collection)
|
||||
}
|
||||
}
|
||||
|
||||
err = app.GetApp().RunInTransaction(func(txApp core.App) error {
|
||||
record.Set("workflowRef", workflowRun.WorkflowId)
|
||||
record.Set("trigger", string(workflowRun.Trigger))
|
||||
record.Set("status", string(workflowRun.Status))
|
||||
record.Set("startedAt", workflowRun.StartedAt)
|
||||
record.Set("endedAt", workflowRun.EndedAt)
|
||||
record.Set("detail", workflowRun.Detail)
|
||||
record.Set("graph", workflowRun.Graph)
|
||||
record.Set("error", workflowRun.Error)
|
||||
err = txApp.Save(record)
|
||||
if err != nil {
|
||||
@@ -122,12 +159,12 @@ func (r *WorkflowRunRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Ex
|
||||
|
||||
func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain.WorkflowRun, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("record is nil")
|
||||
return nil, fmt.Errorf("the record is nil")
|
||||
}
|
||||
|
||||
detail := &domain.WorkflowNode{}
|
||||
if err := record.UnmarshalJSONField("detail", &detail); err != nil {
|
||||
return nil, err
|
||||
graph := &domain.WorkflowGraph{}
|
||||
if err := record.UnmarshalJSONField("graph", &graph); err != nil {
|
||||
return nil, fmt.Errorf("field 'graph' is malformed")
|
||||
}
|
||||
|
||||
workflowRun := &domain.WorkflowRun{
|
||||
@@ -141,7 +178,7 @@ func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain.
|
||||
Trigger: domain.WorkflowTriggerType(record.GetString("trigger")),
|
||||
StartedAt: record.GetDateTime("startedAt").Time(),
|
||||
EndedAt: record.GetDateTime("endedAt").Time(),
|
||||
Detail: detail,
|
||||
Graph: graph,
|
||||
Error: record.GetString("error"),
|
||||
}
|
||||
return workflowRun, nil
|
||||
|
||||
@@ -38,11 +38,12 @@ func (handler *CertificateHandler) archiveFile(e *core.RequestEvent) error {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
if res, err := handler.service.DownloadArchivedFile(e.Request.Context(), req); err != nil {
|
||||
res, err := handler.service.DownloadArchivedFile(e.Request.Context(), req)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
} else {
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
func (handler *CertificateHandler) validateCertificate(e *core.RequestEvent) error {
|
||||
@@ -51,11 +52,12 @@ func (handler *CertificateHandler) validateCertificate(e *core.RequestEvent) err
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
if res, err := handler.service.ValidateCertificate(e.Request.Context(), req); err != nil {
|
||||
res, err := handler.service.ValidateCertificate(e.Request.Context(), req)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
} else {
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
func (handler *CertificateHandler) validatePrivateKey(e *core.RequestEvent) error {
|
||||
@@ -64,9 +66,10 @@ func (handler *CertificateHandler) validatePrivateKey(e *core.RequestEvent) erro
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
if res, err := handler.service.ValidatePrivateKey(e.Request.Context(), req); err != nil {
|
||||
res, err := handler.service.ValidatePrivateKey(e.Request.Context(), req)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
} else {
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ func NewStatisticsHandler(router *router.RouterGroup[*core.RequestEvent], servic
|
||||
}
|
||||
|
||||
func (handler *StatisticsHandler) get(e *core.RequestEvent) error {
|
||||
if statistics, err := handler.service.Get(e.Request.Context()); err != nil {
|
||||
res, err := handler.service.Get(e.Request.Context())
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
} else {
|
||||
return resp.Ok(e, statistics)
|
||||
}
|
||||
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
)
|
||||
|
||||
type workflowService interface {
|
||||
StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) error
|
||||
CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) error
|
||||
StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) (*dtos.WorkflowStartRunResp, error)
|
||||
CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) (*dtos.WorkflowCancelRunResp, error)
|
||||
Shutdown(ctx context.Context)
|
||||
}
|
||||
|
||||
@@ -37,11 +37,12 @@ func (handler *WorkflowHandler) run(e *core.RequestEvent) error {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
if err := handler.service.StartRun(e.Request.Context(), req); err != nil {
|
||||
res, err := handler.service.StartRun(e.Request.Context(), req)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
return resp.Ok(e, nil)
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
func (handler *WorkflowHandler) cancel(e *core.RequestEvent) error {
|
||||
@@ -49,9 +50,10 @@ func (handler *WorkflowHandler) cancel(e *core.RequestEvent) error {
|
||||
req.WorkflowId = e.Request.PathValue("workflowId")
|
||||
req.RunId = e.Request.PathValue("runId")
|
||||
|
||||
if err := handler.service.CancelRun(e.Request.Context(), req); err != nil {
|
||||
res, err := handler.service.CancelRun(e.Request.Context(), req)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
|
||||
return resp.Ok(e, nil)
|
||||
return resp.Ok(e, res)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/app"
|
||||
"github.com/certimate-go/certimate/internal/certificate"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
@@ -17,10 +19,10 @@ func Register() {
|
||||
certificateSvc := certificate.NewCertificateService(certificateRepo, settingsRepo)
|
||||
|
||||
if err := InitWorkflowScheduler(workflowSvc); err != nil {
|
||||
app.GetLogger().Error("failed to init workflow scheduler", "err", err)
|
||||
app.GetLogger().Error("failed to init workflow scheduler", slog.Any("error", err))
|
||||
}
|
||||
|
||||
if err := InitCertificateScheduler(certificateSvc); err != nil {
|
||||
app.GetLogger().Error("failed to init certificate scheduler", "err", err)
|
||||
app.GetLogger().Error("failed to init certificate scheduler", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import (
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type statisticsRepository interface {
|
||||
Get(ctx context.Context) (*domain.Statistics, error)
|
||||
}
|
||||
|
||||
type StatisticsService struct {
|
||||
statRepo statisticsRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package statistics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type statisticsRepository interface {
|
||||
Get(ctx context.Context) (*domain.Statistics, error)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type workflowRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.Workflow, error)
|
||||
Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error)
|
||||
}
|
||||
|
||||
type workflowRunRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.WorkflowRun, error)
|
||||
Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
}
|
||||
|
||||
type workflowLogRepository interface {
|
||||
Save(ctx context.Context, workflowLog *domain.WorkflowLog) (*domain.WorkflowLog, error)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -12,10 +13,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/app"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
"github.com/certimate-go/certimate/internal/workflow/engine"
|
||||
"github.com/certimate-go/certimate/pkg/logging"
|
||||
)
|
||||
|
||||
var maxWorkers = 1
|
||||
@@ -32,275 +34,290 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
type workflowWorker struct {
|
||||
Data *WorkflowWorkerData
|
||||
Cancel context.CancelFunc
|
||||
type WorkflowDispatcher interface {
|
||||
Bootup(ctx context.Context) error
|
||||
Shutdown(ctx context.Context) error
|
||||
Start(ctx context.Context, runId string) error
|
||||
Cancel(ctx context.Context, runId string) error
|
||||
}
|
||||
|
||||
type WorkflowWorkerData struct {
|
||||
WorkflowId string
|
||||
WorkflowContent *domain.WorkflowNode
|
||||
RunId string
|
||||
}
|
||||
type workflowDispatcher struct {
|
||||
booted bool
|
||||
concurrency int
|
||||
|
||||
type WorkflowDispatcher struct {
|
||||
semaphore chan struct{}
|
||||
|
||||
queue []*WorkflowWorkerData
|
||||
queueMutex sync.Mutex
|
||||
|
||||
workers map[string]*workflowWorker // key: WorkflowId
|
||||
workerIdMap map[string]string // key: RunId, value: WorkflowId
|
||||
workerMutex sync.Mutex
|
||||
|
||||
chWork chan *WorkflowWorkerData
|
||||
chCandi chan struct{}
|
||||
|
||||
wg sync.WaitGroup
|
||||
taskMtx sync.RWMutex
|
||||
pendingRunQueue []string
|
||||
processingTasks map[string]*taskInfo // Key: RunId
|
||||
|
||||
workflowRepo workflowRepository
|
||||
workflowRunRepo workflowRunRepository
|
||||
workflowLogRepo workflowLogRepository
|
||||
}
|
||||
|
||||
func newWorkflowDispatcher(workflowRepo workflowRepository, workflowRunRepo workflowRunRepository, workflowLogRepo workflowLogRepository) *WorkflowDispatcher {
|
||||
dispatcher := &WorkflowDispatcher{
|
||||
semaphore: make(chan struct{}, maxWorkers),
|
||||
var _ WorkflowDispatcher = (*workflowDispatcher)(nil)
|
||||
|
||||
queue: make([]*WorkflowWorkerData, 0),
|
||||
queueMutex: sync.Mutex{},
|
||||
|
||||
workers: make(map[string]*workflowWorker),
|
||||
workerIdMap: make(map[string]string),
|
||||
workerMutex: sync.Mutex{},
|
||||
|
||||
chWork: make(chan *WorkflowWorkerData),
|
||||
chCandi: make(chan struct{}, 1),
|
||||
|
||||
workflowRepo: workflowRepo,
|
||||
workflowRunRepo: workflowRunRepo,
|
||||
workflowLogRepo: workflowLogRepo,
|
||||
func (wd *workflowDispatcher) Bootup(ctx context.Context) error {
|
||||
if wd.booted {
|
||||
return errors.New("could not re-bootup")
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-dispatcher.chWork:
|
||||
dispatcher.dequeueWorker()
|
||||
wd.taskMtx.Lock()
|
||||
defer wd.taskMtx.Unlock()
|
||||
|
||||
case <-dispatcher.chCandi:
|
||||
dispatcher.dequeueWorker()
|
||||
}
|
||||
}
|
||||
}()
|
||||
if _, err := app.GetDB().NewQuery("UPDATE workflow SET lastRunStatus = 'canceled' WHERE lastRunStatus = 'pending' OR lastRunStatus = 'processing'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := app.GetDB().NewQuery("UPDATE workflow_run SET status = 'canceled' WHERE status = 'pending' OR status = 'processing'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dispatcher
|
||||
wd.booted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) Dispatch(data *WorkflowWorkerData) {
|
||||
if data == nil {
|
||||
panic("worker data is nil")
|
||||
func (wd *workflowDispatcher) Shutdown(ctx context.Context) error {
|
||||
if !wd.booted {
|
||||
return errors.New("could not re-shutdown")
|
||||
}
|
||||
|
||||
d.enqueueWorker(data)
|
||||
wd.taskMtx.Lock()
|
||||
defer wd.taskMtx.Unlock()
|
||||
|
||||
select {
|
||||
case d.chWork <- data:
|
||||
default:
|
||||
for runId, task := range wd.processingTasks {
|
||||
task.cancel()
|
||||
delete(wd.processingTasks, runId)
|
||||
}
|
||||
|
||||
wd.booted = false
|
||||
wd.pendingRunQueue = make([]string, 0)
|
||||
wd.processingTasks = make(map[string]*taskInfo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) Cancel(runId string) {
|
||||
hasWorker := false
|
||||
func (wd *workflowDispatcher) Start(ctx context.Context, runId string) error {
|
||||
wd.taskMtx.Lock()
|
||||
defer wd.taskMtx.Unlock()
|
||||
|
||||
// 取消正在执行的 WorkflowRun
|
||||
d.workerMutex.Lock()
|
||||
if workflowId, ok := d.workerIdMap[runId]; ok {
|
||||
if worker, ok := d.workers[workflowId]; ok {
|
||||
hasWorker = true
|
||||
worker.Cancel()
|
||||
delete(d.workers, workflowId)
|
||||
delete(d.workerIdMap, runId)
|
||||
if _, exists := wd.processingTasks[runId]; exists {
|
||||
return errors.New("workflow run is already processing")
|
||||
}
|
||||
|
||||
for _, pendingRunId := range wd.pendingRunQueue {
|
||||
if pendingRunId == runId {
|
||||
return errors.New("workflow run is already in the queue")
|
||||
}
|
||||
}
|
||||
d.workerMutex.Unlock()
|
||||
|
||||
// 移除排队中的 WorkflowRun
|
||||
d.queueMutex.Lock()
|
||||
d.queue = lo.Filter(d.queue, func(d *WorkflowWorkerData, _ int) bool {
|
||||
return d.RunId != runId
|
||||
})
|
||||
d.queueMutex.Unlock()
|
||||
wd.pendingRunQueue = append(wd.pendingRunQueue, runId)
|
||||
go func() { wd.tryNextAsync() }()
|
||||
|
||||
// 已挂起,查询 WorkflowRun 并更新其状态为 Canceled
|
||||
if !hasWorker {
|
||||
if run, err := d.workflowRunRepo.GetById(context.Background(), runId); err == nil {
|
||||
if run.Status == domain.WorkflowRunStatusTypePending || run.Status == domain.WorkflowRunStatusTypeRunning {
|
||||
run.Status = domain.WorkflowRunStatusTypeCanceled
|
||||
d.workflowRunRepo.Save(context.Background(), run)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wd *workflowDispatcher) Cancel(ctx context.Context, runId string) error {
|
||||
wd.taskMtx.Lock()
|
||||
defer wd.taskMtx.Unlock()
|
||||
|
||||
workflowRun, err := wd.workflowRunRepo.GetById(ctx, runId)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if workflowRun.Status != domain.WorkflowRunStatusTypePending && workflowRun.Status != domain.WorkflowRunStatusTypeProcessing {
|
||||
return errors.New("workflow run is already completed")
|
||||
}
|
||||
|
||||
workflow, err := wd.workflowRepo.GetById(ctx, workflowRun.WorkflowId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeCanceled
|
||||
if workflow.LastRunId == workflowRun.Id {
|
||||
_, err := wd.workflowRunRepo.SaveWithCascading(ctx, workflowRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, err := wd.workflowRunRepo.Save(ctx, workflowRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) Shutdown() {
|
||||
// 清空排队中的 WorkflowRun
|
||||
d.queueMutex.Lock()
|
||||
d.queue = make([]*WorkflowWorkerData, 0)
|
||||
d.queueMutex.Unlock()
|
||||
|
||||
// 等待所有正在执行的 WorkflowRun 完成
|
||||
d.workerMutex.Lock()
|
||||
for _, worker := range d.workers {
|
||||
worker.Cancel()
|
||||
delete(d.workers, worker.Data.WorkflowId)
|
||||
delete(d.workerIdMap, worker.Data.RunId)
|
||||
if task, exists := wd.processingTasks[runId]; exists {
|
||||
task.cancel()
|
||||
delete(wd.processingTasks, runId)
|
||||
}
|
||||
d.workerMutex.Unlock()
|
||||
d.wg.Wait()
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) enqueueWorker(data *WorkflowWorkerData) {
|
||||
d.queueMutex.Lock()
|
||||
defer d.queueMutex.Unlock()
|
||||
d.queue = append(d.queue, data)
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) dequeueWorker() {
|
||||
for {
|
||||
select {
|
||||
case d.semaphore <- struct{}{}:
|
||||
default:
|
||||
// 达到最大并发数
|
||||
return
|
||||
for i, pendingRunId := range wd.pendingRunQueue {
|
||||
if pendingRunId == runId {
|
||||
wd.pendingRunQueue = append(wd.pendingRunQueue[:i], wd.pendingRunQueue[i+1:]...)
|
||||
break
|
||||
}
|
||||
|
||||
d.queueMutex.Lock()
|
||||
if len(d.queue) == 0 {
|
||||
d.queueMutex.Unlock()
|
||||
<-d.semaphore
|
||||
return
|
||||
}
|
||||
|
||||
data := d.queue[0]
|
||||
d.queue = d.queue[1:]
|
||||
d.queueMutex.Unlock()
|
||||
|
||||
// 检查是否有相同 WorkflowId 的 WorkflowRun 正在执行
|
||||
// 如果有,则重新排队,以保证同一个工作流同一时间内只有一个正在执行
|
||||
// 即不同 WorkflowId 的任务并行化,相同 WorkflowId 的任务串行化
|
||||
d.workerMutex.Lock()
|
||||
if _, exists := d.workers[data.WorkflowId]; exists {
|
||||
d.queueMutex.Lock()
|
||||
d.queue = append(d.queue, data)
|
||||
d.queueMutex.Unlock()
|
||||
d.workerMutex.Unlock()
|
||||
|
||||
<-d.semaphore
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d.workers[data.WorkflowId] = &workflowWorker{data, cancel}
|
||||
d.workerIdMap[data.RunId] = data.WorkflowId
|
||||
d.workerMutex.Unlock()
|
||||
|
||||
d.wg.Add(1)
|
||||
go d.work(ctx, data)
|
||||
}
|
||||
|
||||
go func() { wd.tryNextAsync() }()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *WorkflowDispatcher) work(ctx context.Context, data *WorkflowWorkerData) {
|
||||
var run *domain.WorkflowRun
|
||||
var err error
|
||||
func (wd *workflowDispatcher) tryExecuteAsync(task *taskInfo) {
|
||||
var workflowRun *domain.WorkflowRun
|
||||
|
||||
// 捕获 panic
|
||||
defer func() {
|
||||
// 捕获 panic,避免影响其他工作流的执行
|
||||
if r := recover(); r != nil {
|
||||
log.Default().Println("WorkflowId:", data.WorkflowId, "RunId:", data.RunId)
|
||||
log.Default().Println("Recovered from panic:", r)
|
||||
log.Default().Println("Stack trace:", string(debug.Stack()))
|
||||
if run != nil {
|
||||
run.Status = domain.WorkflowRunStatusTypeFailed
|
||||
run.EndedAt = time.Now()
|
||||
run.Error = fmt.Sprintf("workflow run panic: %v", r)
|
||||
if _, err := d.workflowRunRepo.Save(ctx, run); err != nil {
|
||||
log.Default().Println("Failed to save workflow run after panic:", err)
|
||||
slog.Default().Warn(fmt.Sprintf("workflow dispatcher panic: %v, stack trace: %s", r, string(debug.Stack())), slog.Any("workflowId", task.WorkflowId), slog.Any("runId", task.RunId))
|
||||
app.GetLogger().Error(fmt.Sprintf("workflow dispatcher panic: %v", r), slog.Any("workflowId", task.WorkflowId), slog.Any("runId", task.RunId))
|
||||
|
||||
if workflowRun != nil {
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeFailed
|
||||
workflowRun.EndedAt = time.Now()
|
||||
workflowRun.Error = fmt.Sprintf("workflow dispatcher panic: %v", r)
|
||||
if _, err := wd.workflowRunRepo.SaveWithCascading(context.Background(), workflowRun); err != nil {
|
||||
log.Default().Println("failed to save workflow run after panic", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<-d.semaphore
|
||||
d.workerMutex.Lock()
|
||||
delete(d.workers, data.WorkflowId)
|
||||
delete(d.workerIdMap, data.RunId)
|
||||
d.workerMutex.Unlock()
|
||||
|
||||
d.wg.Done()
|
||||
|
||||
// 尝试取出排队中的其他 WorkflowRun 继续执行
|
||||
select {
|
||||
case d.chCandi <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
// 查询 WorkflowRun
|
||||
run, err = d.workflowRunRepo.GetById(ctx, data.RunId)
|
||||
if err != nil {
|
||||
// 尝试继续执行等待队列中的任务
|
||||
defer func() {
|
||||
delete(wd.processingTasks, task.RunId)
|
||||
wd.tryNextAsync()
|
||||
}()
|
||||
|
||||
// 查询运行实体,并级联更新状态
|
||||
if run, err := wd.workflowRunRepo.GetById(task.ctx, task.RunId); err != nil {
|
||||
if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
app.GetLogger().Error(fmt.Sprintf("failed to get workflow run #%s", data.RunId), "err", err)
|
||||
app.GetLogger().Error(fmt.Sprintf("failed to get workflow run #%s record", task.RunId), slog.Any("error", err))
|
||||
}
|
||||
return
|
||||
} else if run.Status != domain.WorkflowRunStatusTypePending {
|
||||
return
|
||||
} else if ctx.Err() != nil {
|
||||
run.Status = domain.WorkflowRunStatusTypeCanceled
|
||||
d.workflowRunRepo.Save(ctx, run)
|
||||
return
|
||||
} else {
|
||||
workflowRun = run
|
||||
|
||||
if run.Status == domain.WorkflowRunStatusTypePending {
|
||||
run.Status = domain.WorkflowRunStatusTypeProcessing
|
||||
wd.workflowRunRepo.SaveWithCascading(task.ctx, run)
|
||||
} else {
|
||||
// WTF? That should be impossible!
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 WorkflowRun 状态为 Running
|
||||
run.Status = domain.WorkflowRunStatusTypeRunning
|
||||
if _, err := d.workflowRunRepo.Save(ctx, run); err != nil {
|
||||
if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
panic(err)
|
||||
// 初始化工作流引擎
|
||||
logsBuf := make(domain.WorkflowLogs, 0)
|
||||
we := engine.NewWorkflowEngine()
|
||||
we.OnEnd(func(ctx context.Context) error {
|
||||
if errmsg := logsBuf.ErrorString(); errmsg == "" {
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeSucceeded
|
||||
workflowRun.EndedAt = time.Now()
|
||||
} else {
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeFailed
|
||||
workflowRun.EndedAt = time.Now()
|
||||
workflowRun.Error = errmsg
|
||||
}
|
||||
return
|
||||
}
|
||||
wd.workflowRunRepo.SaveWithCascading(task.ctx, workflowRun)
|
||||
return nil
|
||||
})
|
||||
we.OnError(func(ctx context.Context, err error) error {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeCanceled
|
||||
wd.workflowRunRepo.SaveWithCascading(context.Background(), workflowRun)
|
||||
} else {
|
||||
workflowRun.Status = domain.WorkflowRunStatusTypeFailed
|
||||
workflowRun.EndedAt = time.Now()
|
||||
workflowRun.Error = err.Error()
|
||||
wd.workflowRunRepo.SaveWithCascading(task.ctx, workflowRun)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
we.OnNodeError(func(ctx context.Context, node *engine.Node, err error) error {
|
||||
log := domain.WorkflowLog{}
|
||||
log.WorkflowId = task.WorkflowId
|
||||
log.RunId = task.RunId
|
||||
log.NodeId = node.Id
|
||||
log.NodeName = node.Data.Name
|
||||
log.Timestamp = time.Now().UnixMilli()
|
||||
log.Level = int32(slog.LevelError)
|
||||
log.Message = err.Error()
|
||||
log.CreatedAt = time.Now()
|
||||
logsBuf = append(logsBuf, log)
|
||||
|
||||
if _, err := wd.workflowLogRepo.Save(ctx, &log); err != nil {
|
||||
app.GetLogger().Error(err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
we.OnNodeLogging(func(ctx context.Context, node *engine.Node, record logging.Record) error {
|
||||
log := domain.WorkflowLog{}
|
||||
log.WorkflowId = task.WorkflowId
|
||||
log.RunId = task.RunId
|
||||
log.NodeId = node.Id
|
||||
log.NodeName = node.Data.Name
|
||||
log.Timestamp = record.Time.UnixMilli()
|
||||
log.Level = int32(record.Level)
|
||||
log.Message = record.Message
|
||||
log.Data = record.Data
|
||||
log.CreatedAt = time.Now()
|
||||
logsBuf = append(logsBuf, log)
|
||||
|
||||
if _, err := wd.workflowLogRepo.Save(ctx, &log); err != nil {
|
||||
app.GetLogger().Error(err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// 执行工作流
|
||||
invoker := newWorkflowInvokerWithData(d.workflowLogRepo, data)
|
||||
if runErr := invoker.Invoke(ctx); runErr != nil {
|
||||
if errors.Is(runErr, context.Canceled) {
|
||||
run.Status = domain.WorkflowRunStatusTypeCanceled
|
||||
} else {
|
||||
run.Status = domain.WorkflowRunStatusTypeFailed
|
||||
run.EndedAt = time.Now()
|
||||
run.Error = runErr.Error()
|
||||
app.GetLogger().Info(fmt.Sprintf("start to invoke workflow run #%s", task.RunId))
|
||||
we.Invoke(task.ctx, workflowRun.WorkflowId, workflowRun.Id, workflowRun.Graph)
|
||||
}
|
||||
|
||||
func (wd *workflowDispatcher) tryNextAsync() {
|
||||
wd.taskMtx.RLock()
|
||||
|
||||
for i, pendingRunId := range wd.pendingRunQueue {
|
||||
workflowRun, err := wd.workflowRunRepo.GetById(context.Background(), pendingRunId)
|
||||
if err != nil {
|
||||
app.GetLogger().Error(fmt.Sprintf("failed to get workflow run #%s record", pendingRunId), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := d.workflowRunRepo.Save(ctx, run); err != nil {
|
||||
if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
panic(err)
|
||||
var hasSameWorkflowTask bool // 相同 Workflow 的任务同一时间只能有一个 Run 在执行
|
||||
for _, task := range wd.processingTasks {
|
||||
if task.WorkflowId == workflowRun.WorkflowId {
|
||||
hasSameWorkflowTask = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if !hasSameWorkflowTask && len(wd.processingTasks) < wd.concurrency {
|
||||
wd.taskMtx.RUnlock()
|
||||
wd.taskMtx.Lock()
|
||||
defer wd.taskMtx.Unlock()
|
||||
|
||||
// 更新 WorkflowRun 状态为 Succeeded/Failed
|
||||
run.EndedAt = time.Now()
|
||||
run.Error = invoker.GetLogs().ErrorString()
|
||||
if run.Error == "" {
|
||||
run.Status = domain.WorkflowRunStatusTypeSucceeded
|
||||
} else {
|
||||
run.Status = domain.WorkflowRunStatusTypeFailed
|
||||
}
|
||||
if _, err := d.workflowRunRepo.Save(ctx, run); err != nil {
|
||||
if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
panic(err)
|
||||
ctxRun, ctxCancel := context.WithCancel(context.Background())
|
||||
task := &taskInfo{WorkflowId: workflowRun.WorkflowId, RunId: workflowRun.Id, ctx: ctxRun, cancel: ctxCancel}
|
||||
wd.pendingRunQueue = append(wd.pendingRunQueue[:i], wd.pendingRunQueue[i+1:]...)
|
||||
wd.processingTasks[pendingRunId] = task
|
||||
go func() { wd.tryExecuteAsync(task) }()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wd.taskMtx.RUnlock()
|
||||
}
|
||||
|
||||
func newWorkflowDispatcher() WorkflowDispatcher {
|
||||
return &workflowDispatcher{
|
||||
concurrency: maxWorkers,
|
||||
|
||||
pendingRunQueue: make([]string, 0),
|
||||
processingTasks: make(map[string]*taskInfo),
|
||||
|
||||
workflowRepo: repository.NewWorkflowRepository(),
|
||||
workflowRunRepo: repository.NewWorkflowRunRepository(),
|
||||
workflowLogRepo: repository.NewWorkflowLogRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
nodes "github.com/certimate-go/certimate/internal/workflow/node-processor"
|
||||
"github.com/certimate-go/certimate/pkg/logging"
|
||||
)
|
||||
|
||||
type workflowInvoker struct {
|
||||
workflowId string
|
||||
workflowContent *domain.WorkflowNode
|
||||
runId string
|
||||
logs []domain.WorkflowLog
|
||||
|
||||
workflowLogRepo workflowLogRepository
|
||||
}
|
||||
|
||||
func newWorkflowInvokerWithData(workflowLogRepo workflowLogRepository, data *WorkflowWorkerData) *workflowInvoker {
|
||||
if data == nil {
|
||||
panic("worker data is nil")
|
||||
}
|
||||
|
||||
return &workflowInvoker{
|
||||
workflowId: data.WorkflowId,
|
||||
workflowContent: data.WorkflowContent,
|
||||
runId: data.RunId,
|
||||
logs: make([]domain.WorkflowLog, 0),
|
||||
|
||||
workflowLogRepo: workflowLogRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *workflowInvoker) Invoke(ctx context.Context) error {
|
||||
ctx = context.WithValue(ctx, "workflow_id", w.workflowId)
|
||||
ctx = context.WithValue(ctx, "workflow_run_id", w.runId)
|
||||
return w.processNode(ctx, w.workflowContent)
|
||||
}
|
||||
|
||||
func (w *workflowInvoker) GetLogs() domain.WorkflowLogs {
|
||||
return w.logs
|
||||
}
|
||||
|
||||
func (w *workflowInvoker) processNode(ctx context.Context, node *domain.WorkflowNode) error {
|
||||
current := node
|
||||
for current != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if current.Type == domain.WorkflowNodeTypeBranch || current.Type == domain.WorkflowNodeTypeExecuteResultBranch {
|
||||
for _, branch := range current.Branches {
|
||||
if err := w.processNode(ctx, &branch); err != nil {
|
||||
// 并行分支的某一分支发生错误时,忽略此错误,继续执行其他分支
|
||||
if !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var processor nodes.NodeProcessor
|
||||
var procErr error
|
||||
for {
|
||||
if current.Type != domain.WorkflowNodeTypeBranch && current.Type != domain.WorkflowNodeTypeExecuteResultBranch {
|
||||
processor, procErr = nodes.GetProcessor(current)
|
||||
if procErr != nil {
|
||||
panic(procErr)
|
||||
}
|
||||
|
||||
processor.SetLogger(slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
WriteFunc: func(ctx context.Context, record *logging.Record) error {
|
||||
log := domain.WorkflowLog{}
|
||||
log.WorkflowId = w.workflowId
|
||||
log.RunId = w.runId
|
||||
log.NodeId = current.Id
|
||||
log.NodeName = current.Name
|
||||
log.Timestamp = record.Time.UnixMilli()
|
||||
log.Level = int32(record.Level)
|
||||
log.Message = record.Message
|
||||
log.Data = record.Data
|
||||
log.CreatedAt = record.Time
|
||||
if _, err := w.workflowLogRepo.Save(ctx, &log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.logs = append(w.logs, log)
|
||||
return nil
|
||||
},
|
||||
})))
|
||||
|
||||
procErr = processor.Process(ctx)
|
||||
if procErr != nil {
|
||||
if current.Type != domain.WorkflowNodeTypeCondition {
|
||||
processor.GetLogger().Error(procErr.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
nodeOutputs := processor.GetOutputs()
|
||||
if len(nodeOutputs) > 0 {
|
||||
ctx = nodes.AddNodeOutput(ctx, current.Id, nodeOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// TODO: 优化可读性
|
||||
if procErr != nil && current.Type == domain.WorkflowNodeTypeCondition {
|
||||
current = nil
|
||||
procErr = nil
|
||||
return nil
|
||||
} else if procErr != nil && current.Next != nil && current.Next.Type != domain.WorkflowNodeTypeExecuteResultBranch {
|
||||
return procErr
|
||||
} else if procErr != nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeExecuteResultBranch {
|
||||
current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeExecuteFailure)
|
||||
} else if procErr == nil && current.Next != nil && current.Next.Type == domain.WorkflowNodeTypeExecuteResultBranch {
|
||||
current = w.getBranchByType(current.Next.Branches, domain.WorkflowNodeTypeExecuteSuccess)
|
||||
} else {
|
||||
current = current.Next
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *workflowInvoker) getBranchByType(branches []domain.WorkflowNode, nodeType domain.WorkflowNodeType) *domain.WorkflowNode {
|
||||
for _, branch := range branches {
|
||||
if branch.Type == nodeType {
|
||||
return &branch
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +1,17 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
type workflowRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.Workflow, error)
|
||||
Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error)
|
||||
}
|
||||
|
||||
type workflowRunRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.WorkflowRun, error)
|
||||
Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
}
|
||||
|
||||
type workflowLogRepository interface {
|
||||
Save(ctx context.Context, workflowLog *domain.WorkflowLog) (*domain.WorkflowLog, error)
|
||||
}
|
||||
|
||||
var (
|
||||
instance *WorkflowDispatcher
|
||||
instance WorkflowDispatcher
|
||||
intanceOnce sync.Once
|
||||
)
|
||||
|
||||
func GetSingletonDispatcher() *WorkflowDispatcher {
|
||||
func GetSingletonDispatcher() WorkflowDispatcher {
|
||||
intanceOnce.Do(func() {
|
||||
instance = newWorkflowDispatcher(repository.NewWorkflowRepository(), repository.NewWorkflowRunRepository(), repository.NewWorkflowLogRepository())
|
||||
instance = newWorkflowDispatcher()
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package dispatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type taskInfo struct {
|
||||
WorkflowId string
|
||||
RunId string
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type WorkflowContext struct {
|
||||
WorkflowId string
|
||||
RunId string
|
||||
RunGraph *Graph
|
||||
|
||||
engine WorkflowEngine
|
||||
variables VariableManager
|
||||
inputs InOutManager
|
||||
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) SetExecutingWorkflow(workflowId string, runId string, runGraph *Graph) *WorkflowContext {
|
||||
c.WorkflowId = workflowId
|
||||
c.RunId = runId
|
||||
c.RunGraph = runGraph
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) SetEngine(engine WorkflowEngine) *WorkflowContext {
|
||||
c.engine = engine
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) SetVariablesManager(inputs VariableManager) *WorkflowContext {
|
||||
c.variables = inputs
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) SetInputsManager(manager InOutManager) *WorkflowContext {
|
||||
c.inputs = manager
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) SetContext(ctx context.Context) *WorkflowContext {
|
||||
c.ctx = ctx
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *WorkflowContext) Clone() *WorkflowContext {
|
||||
return &WorkflowContext{
|
||||
WorkflowId: c.WorkflowId,
|
||||
RunId: c.RunId,
|
||||
RunGraph: c.RunGraph,
|
||||
|
||||
engine: c.engine,
|
||||
variables: c.variables,
|
||||
inputs: c.inputs,
|
||||
|
||||
ctx: c.ctx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type certificateRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.Certificate, error)
|
||||
GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error)
|
||||
Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error)
|
||||
}
|
||||
|
||||
type workflowRunRepository interface {
|
||||
GetById(ctx context.Context, workflowRunId string) (*domain.WorkflowRun, error)
|
||||
}
|
||||
|
||||
type workflowOutputRepository interface {
|
||||
GetByNodeId(ctx context.Context, workflowNodeId string) (*domain.WorkflowOutput, error)
|
||||
Save(ctx context.Context, workflowOutput *domain.WorkflowOutput) (*domain.WorkflowOutput, error)
|
||||
}
|
||||
|
||||
type settingsRepository interface {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/app"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
"github.com/certimate-go/certimate/pkg/logging"
|
||||
)
|
||||
|
||||
type WorkflowEngine interface {
|
||||
Invoke(ctx context.Context, workflowId string, runId string, graph *Graph) error
|
||||
|
||||
OnStart(callback func(ctx context.Context) error)
|
||||
OnEnd(callback func(ctx context.Context) error)
|
||||
OnError(callback func(ctx context.Context, err error) error)
|
||||
OnNodeStart(callback func(ctx context.Context, node *Node) error)
|
||||
OnNodeEnd(callback func(ctx context.Context, node *Node, res *NodeExecutionResult) error)
|
||||
OnNodeError(callback func(ctx context.Context, node *Node, err error) error)
|
||||
OnNodeLogging(callback func(ctx context.Context, node *Node, log logging.Record) error)
|
||||
}
|
||||
|
||||
type workflowEngine struct {
|
||||
logger *slog.Logger
|
||||
|
||||
executors map[NodeType]NodeExecutor
|
||||
|
||||
hooksMtx sync.RWMutex
|
||||
onStartHooks [](func(ctx context.Context) error)
|
||||
onEndHooks [](func(ctx context.Context) error)
|
||||
onErrorHooks [](func(ctx context.Context, err error) error)
|
||||
onNodeStartHooks [](func(ctx context.Context, node *Node) error)
|
||||
onNodeEndHooks [](func(ctx context.Context, node *Node, res *NodeExecutionResult) error)
|
||||
onNodeErrorHooks [](func(ctx context.Context, node *Node, err error) error)
|
||||
onNodeLoggingHooks [](func(ctx context.Context, node *Node, log logging.Record) error)
|
||||
|
||||
wfoutputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
var _ WorkflowEngine = (*workflowEngine)(nil)
|
||||
|
||||
func (we *workflowEngine) Invoke(ctx context.Context, workflowId string, runId string, runGraph *Graph) error {
|
||||
we.fireOnStartHooks(ctx)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
we.fireOnErrorHooks(ctx, fmt.Errorf("workflow engine panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
wfCtx := (&WorkflowContext{}).
|
||||
SetExecutingWorkflow(workflowId, runId, runGraph).
|
||||
SetEngine(we).
|
||||
SetVariablesManager(newVariableManager()).
|
||||
SetInputsManager(newInOutManager()).
|
||||
SetContext(ctx)
|
||||
if err := we.executeBlocks(wfCtx, runGraph.Nodes); err != nil {
|
||||
if !errors.Is(err, errInterrupted) {
|
||||
we.fireOnErrorHooks(ctx, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
we.fireOnEndHooks(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnStart(callback func(ctx context.Context) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onStartHooks = append(we.onStartHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnEnd(callback func(ctx context.Context) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onEndHooks = append(we.onEndHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnError(callback func(ctx context.Context, err error) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onErrorHooks = append(we.onErrorHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnNodeStart(callback func(ctx context.Context, node *Node) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onNodeStartHooks = append(we.onNodeStartHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnNodeEnd(callback func(ctx context.Context, node *Node, res *NodeExecutionResult) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onNodeEndHooks = append(we.onNodeEndHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnNodeError(callback func(ctx context.Context, node *Node, err error) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onNodeErrorHooks = append(we.onNodeErrorHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) OnNodeLogging(callback func(ctx context.Context, node *Node, log logging.Record) error) {
|
||||
we.hooksMtx.Lock()
|
||||
defer we.hooksMtx.Unlock()
|
||||
we.onNodeLoggingHooks = append(we.onNodeLoggingHooks, callback)
|
||||
}
|
||||
|
||||
func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error {
|
||||
executor, ok := we.executors[node.Type]
|
||||
if !ok {
|
||||
err := fmt.Errorf("workflow engine: no executor registered for node type: '%s'", node.Type)
|
||||
return err
|
||||
} else {
|
||||
logger := slog.New(logging.NewHookHandler(&logging.HookHandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
WriteFunc: func(ctx context.Context, record *logging.Record) error {
|
||||
we.fireOnNodeLoggingHooks(ctx, node, *record)
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
executor.SetLogger(logger)
|
||||
}
|
||||
|
||||
we.fireOnNodeStartHooks(wfCtx.ctx, node)
|
||||
|
||||
execCtx := newNodeExecutionContext(wfCtx, node)
|
||||
execRes, err := executor.Execute(execCtx)
|
||||
if err != nil {
|
||||
we.fireOnNodeErrorHooks(wfCtx.ctx, node, err)
|
||||
return err
|
||||
}
|
||||
|
||||
we.fireOnNodeEndHooks(wfCtx.ctx, node, execRes)
|
||||
|
||||
if execRes != nil {
|
||||
if execRes.Variables != nil {
|
||||
for _, variable := range execRes.Variables {
|
||||
wfCtx.variables.Add(variable)
|
||||
}
|
||||
}
|
||||
|
||||
if execRes.Outputs != nil {
|
||||
for _, output := range execRes.Outputs {
|
||||
wfCtx.inputs.Add(output)
|
||||
}
|
||||
}
|
||||
|
||||
execOutputs := lo.Filter(execRes.Outputs, func(state InOutState, _ int) bool { return state.Persistent })
|
||||
if execRes.outputForced || len(execOutputs) > 0 {
|
||||
output := &domain.WorkflowOutput{
|
||||
WorkflowId: execCtx.WorkflowId,
|
||||
RunId: execCtx.RunId,
|
||||
NodeId: execCtx.Node.Id,
|
||||
NodeConfig: execCtx.Node.Data.Config,
|
||||
Succeeded: true, // 目前恒为 true
|
||||
}
|
||||
if len(execOutputs) > 0 {
|
||||
output.Outputs = lo.Map(execOutputs, func(state InOutState, _ int) *domain.WorkflowOutputEntry {
|
||||
return &domain.WorkflowOutputEntry{
|
||||
Name: state.Name,
|
||||
Type: state.Type,
|
||||
Value: state.ValueString(),
|
||||
ValueType: state.ValueType,
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := we.wfoutputRepo.Save(execCtx.ctx, output); err != nil {
|
||||
we.logger.Warn("failed to save node output")
|
||||
}
|
||||
}
|
||||
|
||||
if execRes.Interrupted {
|
||||
return errInterrupted
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (we *workflowEngine) executeBlocks(wfCtx *WorkflowContext, blocks []*Node) error {
|
||||
for _, node := range blocks {
|
||||
select {
|
||||
case <-wfCtx.ctx.Done():
|
||||
return wfCtx.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := we.executeNode(wfCtx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnStartHooks(ctx context.Context) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onStartHooks {
|
||||
if cbErr := cb(ctx); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onStart hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnEndHooks(ctx context.Context) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onEndHooks {
|
||||
if cbErr := cb(ctx); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onEnd hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnErrorHooks(ctx context.Context, err error) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onErrorHooks {
|
||||
if cbErr := cb(ctx, err); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onError hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnNodeStartHooks(ctx context.Context, node *Node) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onNodeStartHooks {
|
||||
if cbErr := cb(ctx, node); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onNodeStart hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnNodeEndHooks(ctx context.Context, node *Node, result *NodeExecutionResult) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onNodeEndHooks {
|
||||
if cbErr := cb(ctx, node, result); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onNodeEnd hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnNodeErrorHooks(ctx context.Context, node *Node, err error) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onNodeErrorHooks {
|
||||
if cbErr := cb(ctx, node, err); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onNodeError hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (we *workflowEngine) fireOnNodeLoggingHooks(ctx context.Context, node *Node, log logging.Record) {
|
||||
we.hooksMtx.RLock()
|
||||
defer we.hooksMtx.RUnlock()
|
||||
for _, cb := range we.onNodeLoggingHooks {
|
||||
if cbErr := cb(ctx, node, log); cbErr != nil {
|
||||
we.logger.Error("workflow engine: error in onNodeLogging hook", slog.Any("error", cbErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewWorkflowEngine() WorkflowEngine {
|
||||
engine := &workflowEngine{
|
||||
logger: app.GetLogger(),
|
||||
executors: make(map[NodeType]NodeExecutor),
|
||||
wfoutputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
engine.executors[NodeTypeStart] = newStartNodeExecutor()
|
||||
engine.executors[NodeTypeEnd] = newEndNodeExecutor()
|
||||
engine.executors[NodeTypeCondition] = newConditionNodeExecutor()
|
||||
engine.executors[NodeTypeBranchBlock] = newBranchBlockNodeExecutor()
|
||||
engine.executors[NodeTypeTryCatch] = newTryCatchNodeExecutor()
|
||||
engine.executors[NodeTypeTryBlock] = newTryBlockNodeExecutor()
|
||||
engine.executors[NodeTypeCatchBlock] = newCatchBlockNodeExecutor()
|
||||
engine.executors[NodeTypeBizApply] = newBizApplyNodeExecutor()
|
||||
engine.executors[NodeTypeBizUpload] = newBizUploadNodeExecutor()
|
||||
engine.executors[NodeTypeBizMonitor] = newBizMonitorNodeExecutor()
|
||||
engine.executors[NodeTypeBizDeploy] = newBizDeployNodeExecutor()
|
||||
engine.executors[NodeTypeBizNotify] = newBizNotifyNodeExecutor()
|
||||
return engine
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errInterrupted = errors.New("workflow engine: interrupted, may be ended")
|
||||
@@ -0,0 +1,157 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type NodeExecutor interface {
|
||||
withLogger
|
||||
|
||||
Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error)
|
||||
}
|
||||
|
||||
type nodeExecutor struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (e *nodeExecutor) SetLogger(logger *slog.Logger) {
|
||||
e.logger = logger
|
||||
}
|
||||
|
||||
type NodeExecutionContext struct {
|
||||
WorkflowContext
|
||||
|
||||
Node *Node
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetExecutingWorkflow(workflowId string, runId string, runGraph *Graph) *NodeExecutionContext {
|
||||
c.WorkflowContext.SetExecutingWorkflow(workflowId, runId, runGraph)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetExecutingNode(node *Node) *NodeExecutionContext {
|
||||
c.Node = node
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetEngine(engine WorkflowEngine) *NodeExecutionContext {
|
||||
c.WorkflowContext.SetEngine(engine)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetVariablesManager(variables VariableManager) *NodeExecutionContext {
|
||||
c.WorkflowContext.SetVariablesManager(variables)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetInputsManager(inputs InOutManager) *NodeExecutionContext {
|
||||
c.WorkflowContext.SetInputsManager(inputs)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *NodeExecutionContext) SetContext(ctx context.Context) *NodeExecutionContext {
|
||||
c.WorkflowContext.SetContext(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
func newNodeExecutionContext(wfCtx *WorkflowContext, node *Node) *NodeExecutionContext {
|
||||
return (&NodeExecutionContext{}).
|
||||
SetExecutingWorkflow(wfCtx.WorkflowId, wfCtx.RunId, wfCtx.RunGraph).
|
||||
SetExecutingNode(node).
|
||||
SetEngine(wfCtx.engine).
|
||||
SetVariablesManager(wfCtx.variables).
|
||||
SetInputsManager(wfCtx.inputs).
|
||||
SetContext(wfCtx.ctx)
|
||||
}
|
||||
|
||||
type NodeExecutionResult struct {
|
||||
node *Node
|
||||
|
||||
Interrupted bool // 是否中断执行(通常由 End 节点主动触发)
|
||||
|
||||
variablesMtx sync.Mutex
|
||||
Variables []VariableState
|
||||
|
||||
outputForced bool // 即使 Outputs 为空,也强制持久化输出
|
||||
outputsMtx sync.Mutex
|
||||
Outputs []InOutState
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) AddVariable(key string, value any, valueType string) {
|
||||
r.AddVariableWithScope("", key, value, valueType)
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) AddVariableWithScope(scope string, key string, value any, valueType string) {
|
||||
r.addVariableState(VariableState{
|
||||
Scope: scope,
|
||||
Key: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) addVariableState(state VariableState) {
|
||||
r.variablesMtx.Lock()
|
||||
defer r.variablesMtx.Unlock()
|
||||
|
||||
if r.Variables == nil {
|
||||
r.Variables = make([]VariableState, 0)
|
||||
}
|
||||
|
||||
for i, item := range r.Variables {
|
||||
if item.Scope == state.Scope && item.Key == state.Key {
|
||||
r.Variables[i] = state
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Variables = append(r.Variables, state)
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) AddOutput(stype string, key string, value any, valueType string) {
|
||||
r.addOutputState(InOutState{
|
||||
NodeId: r.node.Id,
|
||||
Type: stype,
|
||||
Name: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
Persistent: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) AddOutputWithPersistent(stype string, key string, value any, valueType string) {
|
||||
r.addOutputState(InOutState{
|
||||
NodeId: r.node.Id,
|
||||
Type: stype,
|
||||
Name: key,
|
||||
Value: value,
|
||||
ValueType: valueType,
|
||||
Persistent: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *NodeExecutionResult) addOutputState(state InOutState) {
|
||||
r.outputsMtx.Lock()
|
||||
defer r.outputsMtx.Unlock()
|
||||
|
||||
if r.Outputs == nil {
|
||||
r.Outputs = make([]InOutState, 0)
|
||||
}
|
||||
|
||||
for i, t := range r.Outputs {
|
||||
if t.NodeId == state.NodeId && t.Name == state.Name {
|
||||
r.Outputs[i] = state
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Outputs = append(r.Outputs, state)
|
||||
}
|
||||
|
||||
func newNodeExecutionResult(node *Node) *NodeExecutionResult {
|
||||
return &NodeExecutionResult{
|
||||
node: node,
|
||||
Variables: make([]VariableState, 0),
|
||||
Outputs: make([]InOutState, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/applicant"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
|
||||
)
|
||||
|
||||
/**
|
||||
* Result Variables:
|
||||
* - node.skipped: boolean
|
||||
* - certificate.validity: boolean
|
||||
* - certificate.daysLeft: number
|
||||
*
|
||||
* Result Outputs:
|
||||
* - ref: certificate: string
|
||||
*/
|
||||
type bizApplyNodeExecutor struct {
|
||||
nodeExecutor
|
||||
|
||||
certificateRepo certificateRepository
|
||||
wfoutputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBizApply()
|
||||
ne.logger.Info("ready to request certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx)
|
||||
if err != nil {
|
||||
return execRes, err
|
||||
} else if lastCertificate != nil {
|
||||
execRes.AddOutput(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, lastCertificate.Id), "string")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number")
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable {
|
||||
ne.logger.Info(fmt.Sprintf("skip this application, because %s", reason))
|
||||
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean")
|
||||
return execRes, nil
|
||||
} else if reason != "" {
|
||||
ne.logger.Info(fmt.Sprintf("re-apply, because %s", reason))
|
||||
} else {
|
||||
ne.logger.Info("no found last issued certificate, begin to apply")
|
||||
}
|
||||
|
||||
// 初始化申请器
|
||||
// TODO: 解耦
|
||||
applicant, err := applicant.NewWithWorkflowNode(applicant.ApplicantWithWorkflowNodeConfig{
|
||||
WorkflowId: execCtx.WorkflowId,
|
||||
Node: execCtx.Node,
|
||||
Logger: ne.logger,
|
||||
})
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to create applicant provider")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 申请证书
|
||||
applyResult, err := applicant.Apply(execCtx.ctx)
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to obtain certificate")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 解析证书
|
||||
certX509, err := xcert.ParseCertificateFromPEM(applyResult.FullChainCertificate)
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to parse certificate, may be the CA responded error")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 保存证书实体
|
||||
certificate := &domain.Certificate{
|
||||
Source: domain.CertificateSourceTypeRequest,
|
||||
Certificate: applyResult.FullChainCertificate,
|
||||
PrivateKey: applyResult.PrivateKey,
|
||||
IssuerCertificate: applyResult.IssuerCertificate,
|
||||
ACMEAccountUrl: applyResult.ACMEAccountUrl,
|
||||
ACMECertUrl: applyResult.ACMECertUrl,
|
||||
ACMECertStableUrl: applyResult.ACMECertStableUrl,
|
||||
WorkflowId: execCtx.WorkflowId,
|
||||
WorkflowRunId: execCtx.RunId,
|
||||
WorkflowNodeId: execCtx.Node.Id,
|
||||
}
|
||||
certificate.PopulateFromX509(certX509)
|
||||
if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil {
|
||||
ne.logger.Warn("failed to save certificate")
|
||||
return execRes, err
|
||||
} else {
|
||||
ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id))
|
||||
}
|
||||
|
||||
// 保存 ARI 记录
|
||||
if lastCertificate != nil && applyResult.ARIReplaced {
|
||||
lastCertificate.ACMERenewed = true
|
||||
ne.certificateRepo.Save(execCtx.ctx, lastCertificate)
|
||||
}
|
||||
|
||||
// 节点输出
|
||||
execRes.AddOutputWithPersistent(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, certificate.Id), "string")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number")
|
||||
|
||||
ne.logger.Info("application completed")
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func (ne *bizApplyNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, *domain.Certificate, error) {
|
||||
lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return nil, nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err)
|
||||
}
|
||||
|
||||
if lastOutput != nil {
|
||||
lastCertificate, err := ne.certificateRepo.GetByWorkflowRunIdAndNodeId(execCtx.ctx, lastOutput.RunId, lastOutput.NodeId)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return lastOutput, nil, fmt.Errorf("failed to get last certificate record of node #%s: %w", execCtx.Node.Id, err)
|
||||
}
|
||||
|
||||
return lastOutput, lastCertificate, nil
|
||||
}
|
||||
|
||||
return lastOutput, nil, nil
|
||||
}
|
||||
|
||||
func (ne *bizApplyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) {
|
||||
thisNodeCfg := execCtx.Node.Data.Config.AsBizApply()
|
||||
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
|
||||
lastNodeCfg := lastOutput.NodeConfig.AsBizApply()
|
||||
|
||||
if thisNodeCfg.Domains != lastNodeCfg.Domains {
|
||||
return false, "the configuration item 'Domains' changed"
|
||||
}
|
||||
if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail {
|
||||
return false, "the configuration item 'ContactEmail' changed"
|
||||
}
|
||||
if thisNodeCfg.Provider != lastNodeCfg.Provider {
|
||||
return false, "the configuration item 'Provider' changed"
|
||||
}
|
||||
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
|
||||
return false, "the configuration item 'ProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
|
||||
return false, "the configuration item 'ProviderConfig' changed"
|
||||
}
|
||||
if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider {
|
||||
return false, "the configuration item 'CAProvider' changed"
|
||||
}
|
||||
if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId {
|
||||
return false, "the configuration item 'CAProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) {
|
||||
return false, "the configuration item 'CAProviderConfig' changed"
|
||||
}
|
||||
if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm {
|
||||
return false, "the configuration item 'KeyAlgorithm' changed"
|
||||
}
|
||||
}
|
||||
|
||||
if lastCertificate != nil {
|
||||
renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
|
||||
expirationTime := time.Until(lastCertificate.ValidityNotAfter)
|
||||
daysLeft := int(math.Floor(expirationTime.Hours() / 24))
|
||||
if expirationTime > renewalInterval {
|
||||
return true, fmt.Sprintf("the last issued certificate #%s expires in %d day(s), next renewal will be in %d day(s)", lastCertificate.Id, daysLeft, thisNodeCfg.SkipBeforeExpiryDays)
|
||||
}
|
||||
|
||||
return false, fmt.Sprintf("the last issued certificate #%s expires in %d day(s)", lastCertificate.Id, daysLeft)
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func newBizApplyNodeExecutor() NodeExecutor {
|
||||
return &bizApplyNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
certificateRepo: repository.NewCertificateRepository(),
|
||||
wfoutputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/deployer"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
/**
|
||||
* Result Variables:
|
||||
* - node.skipped: boolean
|
||||
*/
|
||||
type bizDeployNodeExecutor struct {
|
||||
nodeExecutor
|
||||
|
||||
certificateRepo certificateRepository
|
||||
wfoutputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBizDeploy()
|
||||
ne.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, err := ne.getLastOutputArtifacts(execCtx)
|
||||
if err != nil {
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 获取前序节点输出证书
|
||||
var inputCertificate *domain.Certificate
|
||||
if inputState, ok := execCtx.inputs.Get(nodeCfg.CertificateOutputNodeId, "certificate"); ok {
|
||||
if inputStateValue, ok := inputState.Value.(string); ok {
|
||||
s := strings.Split(inputStateValue, "#")
|
||||
if len(s) == 2 {
|
||||
certificate, err := ne.certificateRepo.GetById(execCtx.ctx, s[1])
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to get input certificate")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
inputCertificate = certificate
|
||||
}
|
||||
}
|
||||
}
|
||||
if inputCertificate == nil {
|
||||
return execRes, fmt.Errorf("invalid input certificate")
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if lastOutput != nil && inputCertificate.CreatedAt.Before(lastOutput.UpdatedAt) {
|
||||
if skippable, reason := ne.checkCanSkip(execCtx, lastOutput); skippable {
|
||||
ne.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason))
|
||||
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean")
|
||||
return execRes, nil
|
||||
} else if reason != "" {
|
||||
ne.logger.Info(fmt.Sprintf("re-deploy, because %s", reason))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化部署器
|
||||
// TODO: 解耦
|
||||
deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{
|
||||
Node: execCtx.Node,
|
||||
Logger: ne.logger,
|
||||
CertificatePEM: inputCertificate.Certificate,
|
||||
PrivateKeyPEM: inputCertificate.PrivateKey,
|
||||
})
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to create deployer provider")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 部署证书
|
||||
if err := deployer.Deploy(execCtx.ctx); err != nil {
|
||||
ne.logger.Warn("failed to deploy certificate")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 节点输出
|
||||
execRes.outputForced = true
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean")
|
||||
|
||||
ne.logger.Info("deployment completed")
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func (ne *bizDeployNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, error) {
|
||||
lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err)
|
||||
}
|
||||
|
||||
return lastOutput, nil
|
||||
}
|
||||
|
||||
func (ne *bizDeployNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
|
||||
thisNodeCfg := execCtx.Node.Data.Config.AsBizDeploy()
|
||||
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
|
||||
lastNodeCfg := lastOutput.NodeConfig.AsBizDeploy()
|
||||
|
||||
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
|
||||
return false, "the configuration item 'ProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
|
||||
return false, "the configuration item 'ProviderConfig' changed"
|
||||
}
|
||||
|
||||
if thisNodeCfg.SkipOnLastSucceeded {
|
||||
return true, "the last deployment already completed"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func newBizDeployNodeExecutor() NodeExecutor {
|
||||
return &bizDeployNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
certificateRepo: repository.NewCertificateRepository(),
|
||||
wfoutputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
xhttp "github.com/certimate-go/certimate/pkg/utils/http"
|
||||
xtls "github.com/certimate-go/certimate/pkg/utils/tls"
|
||||
)
|
||||
|
||||
/**
|
||||
* Result Variables:
|
||||
* - certificate.validity: boolean
|
||||
* - certificate.daysLeft: number
|
||||
*/
|
||||
type bizMonitorNodeExecutor struct {
|
||||
nodeExecutor
|
||||
|
||||
certificateRepo certificateRepository
|
||||
}
|
||||
|
||||
func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBizMonitor()
|
||||
ne.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
targetAddr := net.JoinHostPort(nodeCfg.Host, strconv.Itoa(int(nodeCfg.Port)))
|
||||
if nodeCfg.Port == 0 {
|
||||
targetAddr = net.JoinHostPort(nodeCfg.Host, "443")
|
||||
}
|
||||
|
||||
targetDomain := nodeCfg.Domain
|
||||
if targetDomain == "" {
|
||||
targetDomain = nodeCfg.Host
|
||||
}
|
||||
|
||||
ne.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain))
|
||||
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_INTERVAL = 2 * time.Second
|
||||
var err error
|
||||
var certs []*x509.Certificate
|
||||
for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ {
|
||||
if attempt > 0 {
|
||||
ne.logger.Info(fmt.Sprintf("retry %d time(s) ...", attempt, targetAddr))
|
||||
|
||||
select {
|
||||
case <-execCtx.ctx.Done():
|
||||
return execRes, execCtx.ctx.Err()
|
||||
case <-time.After(RETRY_INTERVAL):
|
||||
}
|
||||
}
|
||||
|
||||
certs, err = ne.tryRetrievePeerCertificates(execCtx, targetAddr, targetDomain, nodeCfg.RequestPath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to monitor certificate")
|
||||
return execRes, err
|
||||
} else {
|
||||
if len(certs) == 0 {
|
||||
ne.logger.Warn("no ssl certificates retrieved in http response")
|
||||
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, false, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, 0, "number")
|
||||
} else {
|
||||
cert := certs[0] // 只取证书链中的第一个证书,即服务器证书
|
||||
ne.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')",
|
||||
cert.SerialNumber, cert.Subject.String(), cert.Issuer.String(),
|
||||
cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339),
|
||||
strings.Join(cert.DNSNames, ";")),
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
isCertPeriodValid := now.Before(cert.NotAfter) && now.After(cert.NotBefore)
|
||||
isCertHostMatched := true
|
||||
if err := cert.VerifyHostname(targetDomain); err != nil {
|
||||
isCertHostMatched = false
|
||||
}
|
||||
|
||||
validated := isCertPeriodValid && isCertHostMatched
|
||||
daysLeft := int(math.Floor(time.Until(cert.NotAfter).Hours() / 24))
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, validated, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, daysLeft, "number")
|
||||
|
||||
if validated {
|
||||
ne.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft))
|
||||
} else {
|
||||
if !isCertHostMatched {
|
||||
ne.logger.Warn("the certificate is invalid, because it is not matched the host")
|
||||
} else if !isCertPeriodValid {
|
||||
ne.logger.Warn("the certificate is invalid, because it is either expired or not yet valid")
|
||||
} else {
|
||||
ne.logger.Warn("the certificate is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ne.logger.Info("monitoring completed")
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func (ne *bizMonitorNodeExecutor) tryRetrievePeerCertificates(execCtx *NodeExecutionContext, addr, domain, requestPath string) ([]*x509.Certificate, error) {
|
||||
transport := xhttp.NewDefaultTransport()
|
||||
transport.TLSClientConfig = xtls.NewInsecureConfig()
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/"))
|
||||
req, err := http.NewRequestWithContext(execCtx.ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to create http request: %w", err)
|
||||
ne.logger.Warn(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", domain)
|
||||
req.Header.Set("User-Agent", "certimate")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to send http request: %w", err)
|
||||
ne.logger.Warn(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return make([]*x509.Certificate, 0), nil
|
||||
}
|
||||
return resp.TLS.PeerCertificates, nil
|
||||
}
|
||||
|
||||
func newBizMonitorNodeExecutor() NodeExecutor {
|
||||
return &bizMonitorNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
certificateRepo: repository.NewCertificateRepository(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/notify"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
type bizNotifyNodeExecutor struct {
|
||||
nodeExecutor
|
||||
|
||||
settingsRepo settingsRepository
|
||||
}
|
||||
|
||||
func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBizNotify()
|
||||
ne.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable := ne.checkCanSkip(execCtx); skippable {
|
||||
ne.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped"))
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
// 初始化通知器
|
||||
deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{
|
||||
Node: execCtx.Node,
|
||||
Logger: ne.logger,
|
||||
Subject: nodeCfg.Subject,
|
||||
Message: nodeCfg.Message,
|
||||
})
|
||||
if err != nil {
|
||||
ne.logger.Warn("failed to create notifier provider")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
// 推送通知
|
||||
if err := deployer.Notify(execCtx.ctx); err != nil {
|
||||
ne.logger.Warn("failed to send notification")
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
ne.logger.Info("notification completed")
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_skip bool) {
|
||||
thisNodeCfg := execCtx.Node.Data.Config.AsBizNotify()
|
||||
if !thisNodeCfg.SkipOnAllPrevSkipped {
|
||||
return false
|
||||
}
|
||||
|
||||
var total, skipped int32
|
||||
for _, variable := range execCtx.variables.All() {
|
||||
if variable.Scope != "" && variable.Key == stateVarKeyNodeSkipped {
|
||||
total++
|
||||
if variable.Value == true {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total > 0 && skipped == total
|
||||
}
|
||||
|
||||
func newBizNotifyNodeExecutor() NodeExecutor {
|
||||
return &bizNotifyNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
settingsRepo: repository.NewSettingsRepository(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
/**
|
||||
* Result Variables:
|
||||
* - node.skipped: boolean
|
||||
* - certificate.validity: boolean
|
||||
* - certificate.daysLeft: number
|
||||
*
|
||||
* Result Outputs:
|
||||
* - ref: certificate: string
|
||||
*/
|
||||
type bizUploadNodeExecutor struct {
|
||||
nodeExecutor
|
||||
|
||||
certificateRepo certificateRepository
|
||||
wfoutputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBizUpload()
|
||||
ne.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, lastCertificate, err := ne.getLastOutputArtifacts(execCtx)
|
||||
if err != nil {
|
||||
return execRes, err
|
||||
} else if lastCertificate != nil {
|
||||
execRes.AddOutput(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, lastCertificate.Id), "string")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, time.Now().After(lastCertificate.ValidityNotAfter), "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(lastCertificate.ValidityNotAfter).Hours()/24), "number")
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable, reason := ne.checkCanSkip(execCtx, lastOutput, lastCertificate); skippable {
|
||||
ne.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason))
|
||||
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, true, "boolean")
|
||||
return execRes, nil
|
||||
} else if reason != "" {
|
||||
ne.logger.Info(fmt.Sprintf("re-upload, because %s", reason))
|
||||
} else {
|
||||
ne.logger.Info("no found last uploaded certificate, begin to upload")
|
||||
}
|
||||
|
||||
// 保存证书实体
|
||||
certificate := &domain.Certificate{
|
||||
Source: domain.CertificateSourceTypeUpload,
|
||||
WorkflowId: execCtx.WorkflowId,
|
||||
WorkflowRunId: execCtx.RunId,
|
||||
WorkflowNodeId: execCtx.Node.Id,
|
||||
}
|
||||
certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey)
|
||||
if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil {
|
||||
ne.logger.Warn("failed to save certificate")
|
||||
return execRes, err
|
||||
} else {
|
||||
ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id))
|
||||
}
|
||||
|
||||
// 节点输出
|
||||
execRes.AddOutputWithPersistent(stateIOTypeRef, "certificate", fmt.Sprintf("%s#%s", domain.CollectionNameCertificate, certificate.Id), "string")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyNodeSkipped, false, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateValidity, true, "boolean")
|
||||
execRes.AddVariableWithScope(execCtx.Node.Id, stateVarKeyCertificateDaysLeft, int32(time.Until(certificate.ValidityNotAfter).Hours()/24), "number")
|
||||
|
||||
ne.logger.Info("uploading completed")
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func (ne *bizUploadNodeExecutor) getLastOutputArtifacts(execCtx *NodeExecutionContext) (*domain.WorkflowOutput, *domain.Certificate, error) {
|
||||
lastOutput, err := ne.wfoutputRepo.GetByNodeId(execCtx.ctx, execCtx.Node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return nil, nil, fmt.Errorf("failed to get last output record of node #%s: %w", execCtx.Node.Id, err)
|
||||
}
|
||||
|
||||
if lastOutput != nil {
|
||||
lastCertificate, err := ne.certificateRepo.GetByWorkflowRunIdAndNodeId(execCtx.ctx, lastOutput.RunId, lastOutput.NodeId)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return lastOutput, nil, fmt.Errorf("failed to get last certificate record of node #%s: %w", execCtx.Node.Id, err)
|
||||
}
|
||||
|
||||
return lastOutput, lastCertificate, nil
|
||||
}
|
||||
|
||||
return lastOutput, nil, nil
|
||||
}
|
||||
|
||||
func (ne *bizUploadNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, lastOutput *domain.WorkflowOutput, lastCertificate *domain.Certificate) (_skip bool, _reason string) {
|
||||
thisNodeCfg := execCtx.Node.Data.Config.AsBizUpload()
|
||||
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
|
||||
lastNodeCfg := lastOutput.NodeConfig.AsBizUpload()
|
||||
|
||||
if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) {
|
||||
return false, "the configuration item 'Certificate' changed"
|
||||
}
|
||||
if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) {
|
||||
return false, "the configuration item 'PrivateKey' changed"
|
||||
}
|
||||
}
|
||||
|
||||
if lastCertificate != nil {
|
||||
return true, "the last uploaded certificate already exists"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func newBizUploadNodeExecutor() NodeExecutor {
|
||||
return &bizUploadNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
certificateRepo: repository.NewCertificateRepository(),
|
||||
wfoutputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type conditionNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (ne *conditionNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
var engine *workflowEngine
|
||||
if we, ok := execCtx.engine.(*workflowEngine); !ok {
|
||||
panic("impossible!")
|
||||
} else {
|
||||
engine = we
|
||||
}
|
||||
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
errs := make([]error, 0)
|
||||
blocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeBranchBlock })
|
||||
for _, node := range blocks {
|
||||
select {
|
||||
case <-execCtx.ctx.Done():
|
||||
return execRes, execCtx.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := engine.executeNode(execCtx.Clone(), node)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInterrupted) {
|
||||
return execRes, err
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newConditionNodeExecutor() NodeExecutor {
|
||||
return &conditionNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
|
||||
type branchBlockNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (ne *branchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
nodeCfg := execCtx.Node.Data.Config.AsBranchBlock()
|
||||
if nodeCfg.Expression == nil {
|
||||
ne.logger.Info("enter this branch without any conditions")
|
||||
} else {
|
||||
variables := lo.Reduce(execCtx.variables.All(), func(acc map[string]map[string]any, state VariableState, _ int) map[string]map[string]any {
|
||||
if _, ok := acc[state.Scope]; !ok {
|
||||
acc[state.Scope] = make(map[string]any)
|
||||
}
|
||||
|
||||
// 这里需要把所有值都转换为字符串形式,因为 Expression.Eval 仅支持字符串类型的值
|
||||
acc[state.Scope][state.Key] = state.ValueString()
|
||||
return acc
|
||||
}, make(map[string]map[string]any))
|
||||
|
||||
rs, err := nodeCfg.Expression.Eval(variables)
|
||||
if err != nil {
|
||||
ne.logger.Warn(fmt.Sprintf("failed to eval expr: %+v", err))
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
if rs.Value == false {
|
||||
ne.logger.Info("skip this branch, because condition not met")
|
||||
return execRes, nil
|
||||
} else {
|
||||
ne.logger.Info("enter this branch, because condition met")
|
||||
}
|
||||
}
|
||||
|
||||
if engine, ok := execCtx.engine.(*workflowEngine); !ok {
|
||||
panic("impossible!")
|
||||
} else {
|
||||
if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil {
|
||||
return execRes, err
|
||||
}
|
||||
}
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newBranchBlockNodeExecutor() NodeExecutor {
|
||||
return &branchBlockNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type endNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (e *endNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
execRes.Interrupted = true
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newEndNodeExecutor() NodeExecutor {
|
||||
return &endNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type startNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (e *startNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newStartNodeExecutor() NodeExecutor {
|
||||
return &startNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type tryCatchNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (ne *tryCatchNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
var engine *workflowEngine
|
||||
if we, ok := execCtx.engine.(*workflowEngine); !ok {
|
||||
panic("impossible!")
|
||||
} else {
|
||||
engine = we
|
||||
}
|
||||
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
tryErrs := make([]error, 0)
|
||||
tryBlocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeTryBlock })
|
||||
for _, node := range tryBlocks {
|
||||
select {
|
||||
case <-execCtx.ctx.Done():
|
||||
return execRes, execCtx.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := engine.executeNode(execCtx.Clone(), node)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInterrupted) {
|
||||
return execRes, err
|
||||
}
|
||||
tryErrs = append(tryErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tryErrs) > 0 {
|
||||
catchErrs := make([]error, 0)
|
||||
catchBlocks := lo.Filter(execCtx.Node.Blocks, func(n *Node, _ int) bool { return n.Type == NodeTypeCatchBlock })
|
||||
for _, node := range catchBlocks {
|
||||
select {
|
||||
case <-execCtx.ctx.Done():
|
||||
return execRes, execCtx.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
err := engine.executeNode(execCtx.Clone(), node)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInterrupted) {
|
||||
return execRes, err
|
||||
}
|
||||
catchErrs = append(catchErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(catchErrs) > 0 {
|
||||
return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(append(tryErrs, catchErrs...)...))
|
||||
}
|
||||
|
||||
return execRes, fmt.Errorf("error occurred when executing child nodes: %w", errors.Join(tryErrs...))
|
||||
}
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newTryCatchNodeExecutor() NodeExecutor {
|
||||
return &tryCatchNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
|
||||
type tryBlockNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (ne *tryBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
var engine *workflowEngine
|
||||
if we, ok := execCtx.engine.(*workflowEngine); !ok {
|
||||
panic("impossible!")
|
||||
} else {
|
||||
engine = we
|
||||
}
|
||||
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil {
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newTryBlockNodeExecutor() NodeExecutor {
|
||||
return &tryBlockNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
|
||||
type catchBlockNodeExecutor struct {
|
||||
nodeExecutor
|
||||
}
|
||||
|
||||
func (ne *catchBlockNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExecutionResult, error) {
|
||||
execRes := newNodeExecutionResult(execCtx.Node)
|
||||
|
||||
var engine *workflowEngine
|
||||
if we, ok := execCtx.engine.(*workflowEngine); !ok {
|
||||
panic("impossible!")
|
||||
} else {
|
||||
engine = we
|
||||
}
|
||||
|
||||
if err := engine.executeBlocks(execCtx.Clone(), execCtx.Node.Blocks); err != nil {
|
||||
return execRes, err
|
||||
}
|
||||
|
||||
return execRes, nil
|
||||
}
|
||||
|
||||
func newCatchBlockNodeExecutor() NodeExecutor {
|
||||
return &catchBlockNodeExecutor{
|
||||
nodeExecutor: nodeExecutor{logger: slog.Default()},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type withLogger interface {
|
||||
SetLogger(logger *slog.Logger)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type Node = domain.WorkflowNode
|
||||
|
||||
type NodeType = domain.WorkflowNodeType
|
||||
|
||||
const (
|
||||
NodeTypeStart = domain.WorkflowNodeTypeStart
|
||||
NodeTypeEnd = domain.WorkflowNodeTypeEnd
|
||||
NodeTypeCondition = domain.WorkflowNodeTypeCondition
|
||||
NodeTypeBranchBlock = domain.WorkflowNodeTypeBranchBlock
|
||||
NodeTypeTryCatch = domain.WorkflowNodeTypeTryCatch
|
||||
NodeTypeTryBlock = domain.WorkflowNodeTypeTryBlock
|
||||
NodeTypeCatchBlock = domain.WorkflowNodeTypeCatchBlock
|
||||
NodeTypeBizApply = domain.WorkflowNodeTypeBizApply
|
||||
NodeTypeBizUpload = domain.WorkflowNodeTypeBizUpload
|
||||
NodeTypeBizMonitor = domain.WorkflowNodeTypeBizMonitor
|
||||
NodeTypeBizDeploy = domain.WorkflowNodeTypeBizDeploy
|
||||
NodeTypeBizNotify = domain.WorkflowNodeTypeBizNotify
|
||||
)
|
||||
|
||||
type Graph = domain.WorkflowGraph
|
||||
@@ -0,0 +1,283 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type VariableState struct {
|
||||
Scope string // 零值时表示全局的,否则表示指定节点的
|
||||
Key string
|
||||
Value any
|
||||
ValueType string
|
||||
}
|
||||
|
||||
func (s VariableState) ValueString() string {
|
||||
switch s.ValueType {
|
||||
case "string":
|
||||
return s.Value.(string)
|
||||
case "number":
|
||||
return fmt.Sprintf("%d", s.Value)
|
||||
case "boolean":
|
||||
return strconv.FormatBool(s.Value.(bool))
|
||||
default:
|
||||
return fmt.Sprintf("%v", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
type VariableManager interface {
|
||||
All() []VariableState
|
||||
Erase()
|
||||
|
||||
Add(entry VariableState)
|
||||
Set(name string, value any, valueType string)
|
||||
SetScoped(scope string, name string, value any, valueType string)
|
||||
Get(name string) (*VariableState, bool)
|
||||
GetScoped(scope string, key string) (*VariableState, bool)
|
||||
Take(key string) (*VariableState, bool)
|
||||
TakeScoped(scope string, key string) (*VariableState, bool)
|
||||
Remove(key string) bool
|
||||
RemoveScoped(scope string, key string) bool
|
||||
}
|
||||
|
||||
type variableManager struct {
|
||||
statesMtx sync.RWMutex
|
||||
states []VariableState
|
||||
}
|
||||
|
||||
var _ VariableManager = (*variableManager)(nil)
|
||||
|
||||
func (m *variableManager) All() []VariableState {
|
||||
m.statesMtx.RLock()
|
||||
defer m.statesMtx.RUnlock()
|
||||
|
||||
if m.states == nil {
|
||||
return make([]VariableState, 0)
|
||||
}
|
||||
|
||||
return slices.Clone(m.states)
|
||||
}
|
||||
|
||||
func (m *variableManager) Erase() {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
m.states = make([]VariableState, 0)
|
||||
}
|
||||
|
||||
func (m *variableManager) Add(state VariableState) {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
if m.states == nil {
|
||||
m.states = make([]VariableState, 0)
|
||||
}
|
||||
|
||||
for i, item := range m.states {
|
||||
if item.Scope == state.Scope && item.Key == state.Key {
|
||||
m.states[i] = state
|
||||
return
|
||||
}
|
||||
}
|
||||
m.states = append(m.states, state)
|
||||
}
|
||||
|
||||
func (m *variableManager) Set(key string, value any, valueType string) {
|
||||
m.SetScoped("", key, value, valueType)
|
||||
}
|
||||
|
||||
func (m *variableManager) SetScoped(scope string, key string, value any, valueType string) {
|
||||
m.Add(VariableState{Scope: scope, Key: key, Value: value, ValueType: valueType})
|
||||
}
|
||||
|
||||
func (m *variableManager) Get(key string) (*VariableState, bool) {
|
||||
return m.GetScoped("", key)
|
||||
}
|
||||
|
||||
func (m *variableManager) GetScoped(scope string, key string) (*VariableState, bool) {
|
||||
m.statesMtx.RLock()
|
||||
defer m.statesMtx.RUnlock()
|
||||
|
||||
if m.states == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, item := range m.states {
|
||||
if item.Scope == scope && item.Key == key {
|
||||
return &item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *variableManager) Take(key string) (*VariableState, bool) {
|
||||
return m.TakeScoped("", key)
|
||||
}
|
||||
|
||||
func (m *variableManager) TakeScoped(scope string, key string) (*VariableState, bool) {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
if m.states == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for i, item := range m.states {
|
||||
if item.Scope == scope && item.Key == key {
|
||||
m.states = slices.Delete(m.states, i, i+1)
|
||||
return &item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *variableManager) Remove(key string) bool {
|
||||
return m.RemoveScoped("", key)
|
||||
}
|
||||
|
||||
func (m *variableManager) RemoveScoped(scope string, key string) bool {
|
||||
_, ok := m.TakeScoped(scope, key)
|
||||
return ok
|
||||
}
|
||||
|
||||
func newVariableManager() VariableManager {
|
||||
return &variableManager{
|
||||
states: make([]VariableState, 0),
|
||||
}
|
||||
}
|
||||
|
||||
type InOutState struct {
|
||||
NodeId string
|
||||
Type string
|
||||
Name string
|
||||
Value any
|
||||
ValueType string
|
||||
Persistent bool
|
||||
}
|
||||
|
||||
func (s InOutState) ValueString() string {
|
||||
switch s.ValueType {
|
||||
case "string":
|
||||
return s.Value.(string)
|
||||
case "number":
|
||||
return fmt.Sprintf("%d", s.Value)
|
||||
case "boolean":
|
||||
return strconv.FormatBool(s.Value.(bool))
|
||||
default:
|
||||
return fmt.Sprintf("%v", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
type InOutManager interface {
|
||||
All() []InOutState
|
||||
Erase()
|
||||
|
||||
Add(state InOutState)
|
||||
Set(nodeId string, stype string, name string, value any, valueType string, persistent bool)
|
||||
Get(nodeId string, name string) (*InOutState, bool)
|
||||
Take(nodeId string, name string) (*InOutState, bool)
|
||||
Remove(nodeId string, name string) bool
|
||||
}
|
||||
|
||||
type inoutManager struct {
|
||||
statesMtx sync.RWMutex
|
||||
states []InOutState
|
||||
}
|
||||
|
||||
var _ InOutManager = (*inoutManager)(nil)
|
||||
|
||||
func (m *inoutManager) All() []InOutState {
|
||||
m.statesMtx.RLock()
|
||||
defer m.statesMtx.RUnlock()
|
||||
|
||||
if m.states == nil {
|
||||
return make([]InOutState, 0)
|
||||
}
|
||||
|
||||
return slices.Clone(m.states)
|
||||
}
|
||||
|
||||
func (m *inoutManager) Erase() {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
m.states = make([]InOutState, 0)
|
||||
}
|
||||
|
||||
func (m *inoutManager) Add(state InOutState) {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
if m.states == nil {
|
||||
m.states = make([]InOutState, 0)
|
||||
}
|
||||
|
||||
for i, item := range m.states {
|
||||
if item.NodeId == state.NodeId && item.Name == state.Name {
|
||||
m.states[i] = state
|
||||
return
|
||||
}
|
||||
}
|
||||
m.states = append(m.states, state)
|
||||
}
|
||||
|
||||
func (m *inoutManager) Set(nodeId string, stype string, name string, value any, valueType string, persistent bool) {
|
||||
m.Add(InOutState{NodeId: nodeId, Type: stype, Name: name, Value: value, ValueType: valueType, Persistent: persistent})
|
||||
}
|
||||
|
||||
func (m *inoutManager) Get(nodeId string, name string) (*InOutState, bool) {
|
||||
m.statesMtx.RLock()
|
||||
defer m.statesMtx.RUnlock()
|
||||
|
||||
if m.states == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, item := range m.states {
|
||||
if item.NodeId == nodeId && item.Name == name {
|
||||
return &item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *inoutManager) Take(nodeId string, name string) (*InOutState, bool) {
|
||||
m.statesMtx.Lock()
|
||||
defer m.statesMtx.Unlock()
|
||||
|
||||
if m.states == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for i, item := range m.states {
|
||||
if item.NodeId == nodeId && item.Name == name {
|
||||
m.states = slices.Delete(m.states, i, i+1)
|
||||
return &item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *inoutManager) Remove(nodeId string, name string) bool {
|
||||
_, ok := m.Take(nodeId, name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func newInOutManager() InOutManager {
|
||||
return &inoutManager{
|
||||
states: make([]InOutState, 0),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
stateIOTypeRef = "ref"
|
||||
stateIOType = "recordRef"
|
||||
)
|
||||
|
||||
const (
|
||||
stateVarKeyNodeSkipped = "node.skipped" // ValueType: "boolean"
|
||||
stateVarKeyCertificateValidity = "certificate.validity" // ValueType: "boolean"
|
||||
stateVarKeyCertificateDaysLeft = "certificate.daysLeft" // ValueType: "number"
|
||||
)
|
||||
@@ -13,35 +13,38 @@ import (
|
||||
)
|
||||
|
||||
func Register() {
|
||||
app := app.GetApp()
|
||||
app.OnRecordCreateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
pb := app.GetApp()
|
||||
pb.OnRecordCreateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := onWorkflowRecordCreateOrUpdate(e.Request.Context(), e.Record); err != nil {
|
||||
app.GetLogger().Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
app.OnRecordUpdateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
pb.OnRecordUpdateRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := onWorkflowRecordCreateOrUpdate(e.Request.Context(), e.Record); err != nil {
|
||||
app.GetLogger().Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
app.OnRecordDeleteRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
pb.OnRecordDeleteRequest(domain.CollectionNameWorkflow).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := onWorkflowRecordDelete(e.Request.Context(), e.Record); err != nil {
|
||||
app.GetLogger().Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/applicant"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
|
||||
)
|
||||
|
||||
type applyNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
|
||||
certRepo certificateRepository
|
||||
outputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func NewApplyNode(node *domain.WorkflowNode) *applyNode {
|
||||
return &applyNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
|
||||
certRepo: repository.NewCertificateRepository(),
|
||||
outputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *applyNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForApply()
|
||||
n.logger.Info("ready to obtain certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
|
||||
n.logger.Info(fmt.Sprintf("skip this application, because %s", reason))
|
||||
return nil
|
||||
} else if reason != "" {
|
||||
n.logger.Info(fmt.Sprintf("re-apply, because %s", reason))
|
||||
}
|
||||
|
||||
// 初始化申请器
|
||||
applicant, err := applicant.NewWithWorkflowNode(applicant.ApplicantWithWorkflowNodeConfig{
|
||||
Node: n.node,
|
||||
Logger: n.logger,
|
||||
})
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to create applicant provider")
|
||||
return err
|
||||
}
|
||||
|
||||
// 申请证书
|
||||
applyResult, err := applicant.Apply(ctx)
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to obtain certificate")
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析证书并生成实体
|
||||
certX509, err := xcert.ParseCertificateFromPEM(applyResult.FullChainCertificate)
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to parse certificate, may be the CA responded error")
|
||||
return err
|
||||
}
|
||||
|
||||
certificate := &domain.Certificate{
|
||||
Source: domain.CertificateSourceTypeRequest,
|
||||
Certificate: applyResult.FullChainCertificate,
|
||||
PrivateKey: applyResult.PrivateKey,
|
||||
IssuerCertificate: applyResult.IssuerCertificate,
|
||||
ACMEAccountUrl: applyResult.ACMEAccountUrl,
|
||||
ACMECertUrl: applyResult.ACMECertUrl,
|
||||
ACMECertStableUrl: applyResult.ACMECertStableUrl,
|
||||
}
|
||||
certificate.PopulateFromX509(certX509)
|
||||
|
||||
// 保存执行结果
|
||||
output := &domain.WorkflowOutput{
|
||||
WorkflowId: getContextWorkflowId(ctx),
|
||||
RunId: getContextWorkflowRunId(ctx),
|
||||
NodeId: n.node.Id,
|
||||
Node: n.node,
|
||||
Succeeded: true,
|
||||
Outputs: n.node.Outputs,
|
||||
}
|
||||
if _, err := n.outputRepo.SaveWithCertificate(ctx, output, certificate); err != nil {
|
||||
n.logger.Warn("failed to save node output")
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存 ARI 记录
|
||||
if applyResult.ARIReplaced && lastOutput != nil {
|
||||
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
|
||||
if lastCertificate != nil {
|
||||
lastCertificate.ACMERenewed = true
|
||||
n.certRepo.Save(ctx, lastCertificate)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录中间结果
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ValidityNotAfter).Hours()/24), 10)
|
||||
|
||||
n.logger.Info("application completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
|
||||
thisNodeCfg := n.node.GetConfigForApply()
|
||||
lastNodeCfg := lastOutput.Node.GetConfigForApply()
|
||||
|
||||
if thisNodeCfg.Domains != lastNodeCfg.Domains {
|
||||
return false, "the configuration item 'Domains' changed"
|
||||
}
|
||||
if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail {
|
||||
return false, "the configuration item 'ContactEmail' changed"
|
||||
}
|
||||
if thisNodeCfg.Provider != lastNodeCfg.Provider {
|
||||
return false, "the configuration item 'Provider' changed"
|
||||
}
|
||||
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
|
||||
return false, "the configuration item 'ProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
|
||||
return false, "the configuration item 'ProviderConfig' changed"
|
||||
}
|
||||
if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider {
|
||||
return false, "the configuration item 'CAProvider' changed"
|
||||
}
|
||||
if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId {
|
||||
return false, "the configuration item 'CAProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) {
|
||||
return false, "the configuration item 'CAProviderConfig' changed"
|
||||
}
|
||||
if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm {
|
||||
return false, "the configuration item 'KeyAlgorithm' changed"
|
||||
}
|
||||
|
||||
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
|
||||
if lastCertificate != nil {
|
||||
renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
|
||||
expirationTime := time.Until(lastCertificate.ValidityNotAfter)
|
||||
if expirationTime > renewalInterval {
|
||||
daysLeft := int(expirationTime.Hours() / 24)
|
||||
// TODO: 优化此处逻辑,[checkCanSkip] 方法不应该修改中间结果,违背单一职责
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
|
||||
|
||||
return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, thisNodeCfg.SkipBeforeExpiryDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/domain/expr"
|
||||
)
|
||||
|
||||
type conditionNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
}
|
||||
|
||||
func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
|
||||
return &conditionNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *conditionNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForCondition()
|
||||
if nodeCfg.Expression == nil {
|
||||
n.logger.Info("without any conditions, enter this branch")
|
||||
return nil
|
||||
}
|
||||
|
||||
rs, err := n.evalExpr(ctx, nodeCfg.Expression)
|
||||
if err != nil {
|
||||
n.logger.Warn(fmt.Sprintf("failed to eval condition expression: %w", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if rs.Value == false {
|
||||
n.logger.Info("condition not met, skip this branch")
|
||||
return errors.New("condition not met") // TODO: 错误处理
|
||||
} else {
|
||||
n.logger.Info("condition met, enter this branch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) {
|
||||
variables := GetAllNodeOutputs(ctx)
|
||||
return expression.Eval(variables)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
const (
|
||||
outputKeyForCertificateValidity = "certificate.validity"
|
||||
outputKeyForCertificateDaysLeft = "certificate.daysLeft"
|
||||
outputKeyForNodeSkipped = "node.skipped"
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 定义上下文键类型,避免键冲突
|
||||
type workflowContextKey string
|
||||
|
||||
const (
|
||||
nodeOutputsKey workflowContextKey = "node_outputs"
|
||||
)
|
||||
|
||||
// 带互斥锁的节点输出容器
|
||||
type nodeOutputsContainer struct {
|
||||
sync.RWMutex
|
||||
outputs map[string]map[string]any
|
||||
}
|
||||
|
||||
// 创建新的并发安全的节点输出容器
|
||||
func newNodeOutputsContainer() *nodeOutputsContainer {
|
||||
return &nodeOutputsContainer{
|
||||
outputs: make(map[string]map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点输出容器
|
||||
func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer {
|
||||
value := ctx.Value(nodeOutputsKey)
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.(*nodeOutputsContainer)
|
||||
}
|
||||
|
||||
// 添加节点输出到上下文
|
||||
func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) context.Context {
|
||||
container := getNodeOutputsContainer(ctx)
|
||||
if container == nil {
|
||||
container = newNodeOutputsContainer()
|
||||
}
|
||||
|
||||
container.Lock()
|
||||
defer container.Unlock()
|
||||
|
||||
// 创建输出的深拷贝
|
||||
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
|
||||
outputCopy := make(map[string]any, len(output))
|
||||
for k, v := range output {
|
||||
outputCopy[k] = v
|
||||
}
|
||||
|
||||
container.outputs[nodeId] = outputCopy
|
||||
return context.WithValue(ctx, nodeOutputsKey, container)
|
||||
}
|
||||
|
||||
// 从上下文获取节点输出
|
||||
func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
|
||||
container := getNodeOutputsContainer(ctx)
|
||||
if container == nil {
|
||||
container = newNodeOutputsContainer()
|
||||
}
|
||||
|
||||
container.RLock()
|
||||
defer container.RUnlock()
|
||||
|
||||
output, exists := container.outputs[nodeId]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
outputCopy := make(map[string]any, len(output))
|
||||
for k, v := range output {
|
||||
outputCopy[k] = v
|
||||
}
|
||||
|
||||
return outputCopy
|
||||
}
|
||||
|
||||
// 获取所有节点输出
|
||||
func GetAllNodeOutputs(ctx context.Context) map[string]map[string]any {
|
||||
container := getNodeOutputsContainer(ctx)
|
||||
if container == nil {
|
||||
container = newNodeOutputsContainer()
|
||||
}
|
||||
|
||||
container.RLock()
|
||||
defer container.RUnlock()
|
||||
|
||||
// 创建所有输出的深拷贝
|
||||
// TODO: 暂时使用浅拷贝,等后续值类型扩充后修改
|
||||
allOutputs := make(map[string]map[string]any, len(container.outputs))
|
||||
for nodeId, output := range container.outputs {
|
||||
nodeCopy := make(map[string]any, len(output))
|
||||
for k, v := range output {
|
||||
nodeCopy[k] = v
|
||||
}
|
||||
allOutputs[nodeId] = nodeCopy
|
||||
}
|
||||
|
||||
return allOutputs
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/deployer"
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
type deployNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
|
||||
certRepo certificateRepository
|
||||
outputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func NewDeployNode(node *domain.WorkflowNode) *deployNode {
|
||||
return &deployNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
|
||||
certRepo: repository.NewCertificateRepository(),
|
||||
outputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *deployNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForDeploy()
|
||||
n.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取前序节点输出证书
|
||||
const DELIMITER = "#"
|
||||
previousNodeOutputCertificateSource := n.node.GetConfigForDeploy().Certificate
|
||||
previousNodeOutputCertificateSourceSlice := strings.Split(previousNodeOutputCertificateSource, DELIMITER)
|
||||
if len(previousNodeOutputCertificateSourceSlice) != 2 {
|
||||
n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource))
|
||||
return fmt.Errorf("invalid certificate source: %s", previousNodeOutputCertificateSource)
|
||||
}
|
||||
certificate, err := n.certRepo.GetByWorkflowNodeId(ctx, previousNodeOutputCertificateSourceSlice[0])
|
||||
if err != nil {
|
||||
n.logger.Warn("invalid certificate source", slog.String("certificate.source", previousNodeOutputCertificateSource))
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
|
||||
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
|
||||
n.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason))
|
||||
return nil
|
||||
} else if reason != "" {
|
||||
n.logger.Info(fmt.Sprintf("re-deploy, because %s", reason))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化部署器
|
||||
deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{
|
||||
Node: n.node,
|
||||
Logger: n.logger,
|
||||
CertificatePEM: certificate.Certificate,
|
||||
PrivateKeyPEM: certificate.PrivateKey,
|
||||
})
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to create deployer provider")
|
||||
return err
|
||||
}
|
||||
|
||||
// 部署证书
|
||||
if err := deployer.Deploy(ctx); err != nil {
|
||||
n.logger.Warn("failed to deploy certificate")
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存执行结果
|
||||
output := &domain.WorkflowOutput{
|
||||
WorkflowId: getContextWorkflowId(ctx),
|
||||
RunId: getContextWorkflowRunId(ctx),
|
||||
NodeId: n.node.Id,
|
||||
Node: n.node,
|
||||
Succeeded: true,
|
||||
}
|
||||
if _, err := n.outputRepo.Save(ctx, output); err != nil {
|
||||
n.logger.Warn("failed to save node output")
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录中间结果
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
|
||||
|
||||
n.logger.Info("deployment completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
|
||||
thisNodeCfg := n.node.GetConfigForDeploy()
|
||||
lastNodeCfg := lastOutput.Node.GetConfigForDeploy()
|
||||
|
||||
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
|
||||
return false, "the configuration item 'ProviderAccessId' changed"
|
||||
}
|
||||
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
|
||||
return false, "the configuration item 'ProviderConfig' changed"
|
||||
}
|
||||
|
||||
if thisNodeCfg.SkipOnLastSucceeded {
|
||||
return true, "the certificate has already been deployed"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type executeFailureNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
}
|
||||
|
||||
func NewExecuteFailureNode(node *domain.WorkflowNode) *executeFailureNode {
|
||||
return &executeFailureNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *executeFailureNode) Process(ctx context.Context) error {
|
||||
// 此类型节点不需要执行任何操作,直接返回
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type executeSuccessNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
}
|
||||
|
||||
func NewExecuteSuccessNode(node *domain.WorkflowNode) *executeSuccessNode {
|
||||
return &executeSuccessNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *executeSuccessNode) Process(ctx context.Context) error {
|
||||
// 此类型节点不需要执行任何操作,直接返回
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
xhttp "github.com/certimate-go/certimate/pkg/utils/http"
|
||||
xtls "github.com/certimate-go/certimate/pkg/utils/tls"
|
||||
)
|
||||
|
||||
type monitorNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
}
|
||||
|
||||
func NewMonitorNode(node *domain.WorkflowNode) *monitorNode {
|
||||
return &monitorNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *monitorNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForMonitor()
|
||||
n.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
targetAddr := net.JoinHostPort(nodeCfg.Host, strconv.Itoa(int(nodeCfg.Port)))
|
||||
if nodeCfg.Port == 0 {
|
||||
targetAddr = net.JoinHostPort(nodeCfg.Host, "443")
|
||||
}
|
||||
|
||||
targetDomain := nodeCfg.Domain
|
||||
if targetDomain == "" {
|
||||
targetDomain = nodeCfg.Host
|
||||
}
|
||||
|
||||
n.logger.Info(fmt.Sprintf("retrieving certificate at %s (domain: %s)", targetAddr, targetDomain))
|
||||
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_INTERVAL = 2 * time.Second
|
||||
var certs []*x509.Certificate
|
||||
var err error
|
||||
for attempt := 0; attempt < MAX_ATTEMPTS; attempt++ {
|
||||
if attempt > 0 {
|
||||
n.logger.Info(fmt.Sprintf("retry %d time(s) ...", attempt, targetAddr))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(RETRY_INTERVAL):
|
||||
}
|
||||
}
|
||||
|
||||
certs, err = n.tryRetrievePeerCertificates(ctx, targetAddr, targetDomain, nodeCfg.RequestPath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to monitor certificate")
|
||||
return err
|
||||
} else {
|
||||
if len(certs) == 0 {
|
||||
n.logger.Warn("no ssl certificates retrieved in http response")
|
||||
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(false)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(0, 10)
|
||||
} else {
|
||||
cert := certs[0] // 只取证书链中的第一个证书,即服务器证书
|
||||
n.logger.Info(fmt.Sprintf("ssl certificate retrieved (serial='%s', subject='%s', issuer='%s', not_before='%s', not_after='%s', sans='%s')",
|
||||
cert.SerialNumber, cert.Subject.String(), cert.Issuer.String(),
|
||||
cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339),
|
||||
strings.Join(cert.DNSNames, ";")),
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
isCertPeriodValid := now.Before(cert.NotAfter) && now.After(cert.NotBefore)
|
||||
isCertHostMatched := true
|
||||
if err := cert.VerifyHostname(targetDomain); err != nil {
|
||||
isCertHostMatched = false
|
||||
}
|
||||
|
||||
validated := isCertPeriodValid && isCertHostMatched
|
||||
daysLeft := int(math.Floor(cert.NotAfter.Sub(now).Hours() / 24))
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(validated)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
|
||||
|
||||
if validated {
|
||||
n.logger.Info(fmt.Sprintf("the certificate is valid, and will expire in %d day(s)", daysLeft))
|
||||
} else {
|
||||
if !isCertHostMatched {
|
||||
n.logger.Warn("the certificate is invalid, because it is not matched the host")
|
||||
} else if !isCertPeriodValid {
|
||||
n.logger.Warn("the certificate is invalid, because it is either expired or not yet valid")
|
||||
} else {
|
||||
n.logger.Warn("the certificate is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n.logger.Info("monitoring completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *monitorNode) tryRetrievePeerCertificates(ctx context.Context, addr, domain, requestPath string) ([]*x509.Certificate, error) {
|
||||
transport := xhttp.NewDefaultTransport()
|
||||
transport.TLSClientConfig = xtls.NewInsecureConfig()
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/%s", addr, strings.TrimLeft(requestPath, "/"))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to create http request: %w", err)
|
||||
n.logger.Warn(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", domain)
|
||||
req.Header.Set("User-Agent", "certimate")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to send http request: %w", err)
|
||||
n.logger.Warn(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return make([]*x509.Certificate, 0), nil
|
||||
}
|
||||
return resp.TLS.PeerCertificates, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package nodeprocessor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
nodeprocessor "github.com/certimate-go/certimate/internal/workflow/node-processor"
|
||||
)
|
||||
|
||||
func Test_MonitorNode(t *testing.T) {
|
||||
t.Run("Monitor", func(t *testing.T) {
|
||||
node := nodeprocessor.NewMonitorNode(&domain.WorkflowNode{
|
||||
Id: "test",
|
||||
Type: domain.WorkflowNodeTypeMonitor,
|
||||
Name: "test",
|
||||
Config: map[string]any{
|
||||
"host": "baidu.com",
|
||||
"port": 443,
|
||||
},
|
||||
})
|
||||
node.SetLogger(slog.Default())
|
||||
if err := node.Process(context.Background()); err != nil {
|
||||
t.Errorf("err: %+v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/notify"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
type notifyNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
|
||||
settingsRepo settingsRepository
|
||||
}
|
||||
|
||||
func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
|
||||
return ¬ifyNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
|
||||
settingsRepo: repository.NewSettingsRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifyNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForNotify()
|
||||
n.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable := n.checkCanSkip(ctx); skippable {
|
||||
n.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 初始化通知器
|
||||
deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{
|
||||
Node: n.node,
|
||||
Logger: n.logger,
|
||||
Subject: nodeCfg.Subject,
|
||||
Message: nodeCfg.Message,
|
||||
})
|
||||
if err != nil {
|
||||
n.logger.Warn("failed to create notifier provider")
|
||||
return err
|
||||
}
|
||||
|
||||
// 推送通知
|
||||
if err := deployer.Notify(ctx); err != nil {
|
||||
n.logger.Warn("failed to send notification")
|
||||
return err
|
||||
}
|
||||
|
||||
n.logger.Info("notification completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notifyNode) checkCanSkip(ctx context.Context) (_skip bool) {
|
||||
thisNodeCfg := n.node.GetConfigForNotify()
|
||||
if !thisNodeCfg.SkipOnAllPrevSkipped {
|
||||
return false
|
||||
}
|
||||
|
||||
prevNodeOutputs := GetAllNodeOutputs(ctx)
|
||||
for _, nodeOutput := range prevNodeOutputs {
|
||||
if nodeOutput[outputKeyForNodeSkipped] != nil {
|
||||
if nodeOutput[outputKeyForNodeSkipped].(string) != strconv.FormatBool(true) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type NodeProcessor interface {
|
||||
GetLogger() *slog.Logger
|
||||
SetLogger(*slog.Logger)
|
||||
|
||||
Process(ctx context.Context) error
|
||||
|
||||
GetOutputs() map[string]any
|
||||
}
|
||||
|
||||
type nodeProcessor struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (n *nodeProcessor) GetLogger() *slog.Logger {
|
||||
return n.logger
|
||||
}
|
||||
|
||||
func (n *nodeProcessor) SetLogger(logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
panic("logger is nil")
|
||||
}
|
||||
|
||||
n.logger = logger
|
||||
}
|
||||
|
||||
type nodeOutputer struct {
|
||||
outputs map[string]any
|
||||
}
|
||||
|
||||
func newNodeOutputer() *nodeOutputer {
|
||||
return &nodeOutputer{
|
||||
outputs: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *nodeOutputer) GetOutputs() map[string]any {
|
||||
return n.outputs
|
||||
}
|
||||
|
||||
type certificateRepository interface {
|
||||
GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error)
|
||||
GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error)
|
||||
Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error)
|
||||
}
|
||||
|
||||
type workflowOutputRepository interface {
|
||||
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 {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
|
||||
func newNodeProcessor(node *domain.WorkflowNode) *nodeProcessor {
|
||||
return &nodeProcessor{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) {
|
||||
switch node.Type {
|
||||
case domain.WorkflowNodeTypeStart:
|
||||
return NewStartNode(node), nil
|
||||
case domain.WorkflowNodeTypeApply:
|
||||
return NewApplyNode(node), nil
|
||||
case domain.WorkflowNodeTypeUpload:
|
||||
return NewUploadNode(node), nil
|
||||
case domain.WorkflowNodeTypeMonitor:
|
||||
return NewMonitorNode(node), nil
|
||||
case domain.WorkflowNodeTypeDeploy:
|
||||
return NewDeployNode(node), nil
|
||||
case domain.WorkflowNodeTypeNotify:
|
||||
return NewNotifyNode(node), nil
|
||||
case domain.WorkflowNodeTypeCondition:
|
||||
return NewConditionNode(node), nil
|
||||
case domain.WorkflowNodeTypeExecuteSuccess:
|
||||
return NewExecuteSuccessNode(node), nil
|
||||
case domain.WorkflowNodeTypeExecuteFailure:
|
||||
return NewExecuteFailureNode(node), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported node type: %s", string(node.Type))
|
||||
}
|
||||
|
||||
func getContextWorkflowId(ctx context.Context) string {
|
||||
return ctx.Value("workflow_id").(string)
|
||||
}
|
||||
|
||||
func getContextWorkflowRunId(ctx context.Context) string {
|
||||
return ctx.Value("workflow_run_id").(string)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type startNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
}
|
||||
|
||||
func NewStartNode(node *domain.WorkflowNode) *startNode {
|
||||
return &startNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *startNode) Process(ctx context.Context) error {
|
||||
// 此类型节点不需要执行任何操作,直接返回
|
||||
n.logger.Info("workflow is started")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package nodeprocessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
"github.com/certimate-go/certimate/internal/repository"
|
||||
)
|
||||
|
||||
type uploadNode struct {
|
||||
node *domain.WorkflowNode
|
||||
*nodeProcessor
|
||||
*nodeOutputer
|
||||
|
||||
certRepo certificateRepository
|
||||
outputRepo workflowOutputRepository
|
||||
}
|
||||
|
||||
func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
|
||||
return &uploadNode{
|
||||
node: node,
|
||||
nodeProcessor: newNodeProcessor(node),
|
||||
nodeOutputer: newNodeOutputer(),
|
||||
|
||||
certRepo: repository.NewCertificateRepository(),
|
||||
outputRepo: repository.NewWorkflowOutputRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *uploadNode) Process(ctx context.Context) error {
|
||||
nodeCfg := n.node.GetConfigForUpload()
|
||||
n.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg))
|
||||
|
||||
// 查询上次执行结果
|
||||
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
|
||||
if err != nil && !domain.IsRecordNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测是否可以跳过本次执行
|
||||
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
|
||||
n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason))
|
||||
return nil
|
||||
} else if reason != "" {
|
||||
n.logger.Info(fmt.Sprintf("re-upload, because %s", reason))
|
||||
}
|
||||
|
||||
// 生成证书实体
|
||||
certificate := &domain.Certificate{
|
||||
Source: domain.CertificateSourceTypeUpload,
|
||||
}
|
||||
certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey)
|
||||
|
||||
// 保存执行结果
|
||||
output := &domain.WorkflowOutput{
|
||||
WorkflowId: getContextWorkflowId(ctx),
|
||||
RunId: getContextWorkflowRunId(ctx),
|
||||
NodeId: n.node.Id,
|
||||
Node: n.node,
|
||||
Succeeded: true,
|
||||
Outputs: n.node.Outputs,
|
||||
}
|
||||
if _, err := n.outputRepo.SaveWithCertificate(ctx, output, certificate); err != nil {
|
||||
n.logger.Warn("failed to save node output")
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录中间结果
|
||||
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ValidityNotAfter).Hours()/24), 10)
|
||||
|
||||
n.logger.Info("uploading completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
|
||||
if lastOutput != nil && lastOutput.Succeeded {
|
||||
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
|
||||
thisNodeCfg := n.node.GetConfigForUpload()
|
||||
lastNodeCfg := lastOutput.Node.GetConfigForUpload()
|
||||
|
||||
if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) {
|
||||
return false, "the configuration item 'Certificate' changed"
|
||||
}
|
||||
if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) {
|
||||
return false, "the configuration item 'PrivateKey' changed"
|
||||
}
|
||||
|
||||
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
|
||||
if lastCertificate != nil {
|
||||
daysLeft := int(time.Until(lastCertificate.ValidityNotAfter).Hours() / 24)
|
||||
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0)
|
||||
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
|
||||
|
||||
return true, "the certificate has already been uploaded"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -14,24 +15,8 @@ import (
|
||||
"github.com/certimate-go/certimate/internal/workflow/dispatcher"
|
||||
)
|
||||
|
||||
type workflowRepository interface {
|
||||
ListEnabledScheduled(ctx context.Context) ([]*domain.Workflow, error)
|
||||
GetById(ctx context.Context, id string) (*domain.Workflow, error)
|
||||
Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error)
|
||||
}
|
||||
|
||||
type workflowRunRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.WorkflowRun, error)
|
||||
Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
|
||||
}
|
||||
|
||||
type settingsRepository interface {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
|
||||
type WorkflowService struct {
|
||||
dispatcher *dispatcher.WorkflowDispatcher
|
||||
dispatcher dispatcher.WorkflowDispatcher
|
||||
|
||||
workflowRepo workflowRepository
|
||||
workflowRunRepo workflowRunRepository
|
||||
@@ -50,33 +35,17 @@ func NewWorkflowService(workflowRepo workflowRepository, workflowRunRepo workflo
|
||||
}
|
||||
|
||||
func (s *WorkflowService) InitSchedule(ctx context.Context) error {
|
||||
// 每日清理工作流执行历史
|
||||
app.GetScheduler().MustAdd("workflowHistoryRunsCleanup", "0 0 * * *", func() {
|
||||
settings, err := s.settingsRepo.GetByName(ctx, "persistence")
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to get persistence settings", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
persistenceSettings, _ := settings.UnmarshalContentAsPersistence()
|
||||
if persistenceSettings != nil && persistenceSettings.WorkflowRunsMaxDaysRetention != 0 {
|
||||
ret, err := s.workflowRunRepo.DeleteWhere(
|
||||
context.Background(),
|
||||
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))),
|
||||
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeRunning))),
|
||||
dbx.NewExp(fmt.Sprintf("endedAt<DATETIME('now', '-%d days')", persistenceSettings.WorkflowRunsMaxDaysRetention)),
|
||||
)
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to delete workflow history runs", "err", err)
|
||||
}
|
||||
|
||||
if ret > 0 {
|
||||
app.GetLogger().Info(fmt.Sprintf("cleanup %d workflow history runs", ret))
|
||||
}
|
||||
}
|
||||
// 每日清理工作流运行历史
|
||||
app.GetScheduler().MustAdd("cleanupWorkflowHistoryRuns", "0 0 * * *", func() {
|
||||
s.cleanupHistoryRuns(context.Background())
|
||||
})
|
||||
|
||||
// 工作流
|
||||
// 初始化工作流调度器
|
||||
if err := s.dispatcher.Bootup(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 注册工作流后台任务
|
||||
{
|
||||
workflows, err := s.workflowRepo.ListEnabledScheduled(ctx)
|
||||
if err != nil {
|
||||
@@ -87,12 +56,13 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error {
|
||||
var errs []error
|
||||
|
||||
err := app.GetScheduler().Add(fmt.Sprintf("workflow#%s", workflow.Id), workflow.TriggerCron, func() {
|
||||
s.StartRun(ctx, &dtos.WorkflowStartRunReq{
|
||||
s.StartRun(context.Background(), &dtos.WorkflowStartRunReq{
|
||||
WorkflowId: workflow.Id,
|
||||
RunTrigger: domain.WorkflowTriggerTypeScheduled,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
app.GetLogger().Error(fmt.Sprintf("failed to add workflow #%s to scheduler: %w", workflow.Id), slog.Any("error", err))
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -105,58 +75,86 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) error {
|
||||
func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) (*dtos.WorkflowStartRunResp, error) {
|
||||
workflow, err := s.workflowRepo.GetById(ctx, req.WorkflowId)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if workflow.LastRunStatus == domain.WorkflowRunStatusTypePending || workflow.LastRunStatus == domain.WorkflowRunStatusTypeRunning {
|
||||
return errors.New("workflow is already pending or running")
|
||||
if workflow.LastRunStatus == domain.WorkflowRunStatusTypePending || workflow.LastRunStatus == domain.WorkflowRunStatusTypeProcessing {
|
||||
return nil, errors.New("workflow is already pending or processing")
|
||||
} else if workflow.GraphContent == nil {
|
||||
return nil, errors.New("workflow graph content is empty")
|
||||
} else if err := workflow.GraphContent.Verify(); err != nil {
|
||||
return nil, fmt.Errorf("workflow graph content is invalid: %w", err)
|
||||
}
|
||||
|
||||
run := &domain.WorkflowRun{
|
||||
workflowRun := &domain.WorkflowRun{
|
||||
WorkflowId: workflow.Id,
|
||||
Status: domain.WorkflowRunStatusTypePending,
|
||||
Trigger: req.RunTrigger,
|
||||
StartedAt: time.Now(),
|
||||
Detail: workflow.Content,
|
||||
Graph: workflow.GraphContent.Clone(),
|
||||
}
|
||||
if resp, err := s.workflowRunRepo.Save(ctx, run); err != nil {
|
||||
return err
|
||||
if resp, err := s.workflowRunRepo.Save(ctx, workflowRun); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
run = resp
|
||||
workflowRun = resp
|
||||
}
|
||||
|
||||
s.dispatcher.Dispatch(&dispatcher.WorkflowWorkerData{
|
||||
WorkflowId: run.WorkflowId,
|
||||
WorkflowContent: run.Detail,
|
||||
RunId: run.Id,
|
||||
})
|
||||
s.dispatcher.Start(ctx, workflowRun.Id)
|
||||
|
||||
return nil
|
||||
return &dtos.WorkflowStartRunResp{RunId: workflowRun.Id}, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowService) CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) error {
|
||||
func (s *WorkflowService) CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) (*dtos.WorkflowCancelRunResp, error) {
|
||||
workflow, err := s.workflowRepo.GetById(ctx, req.WorkflowId)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
workflowRun, err := s.workflowRunRepo.GetById(ctx, req.RunId)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
} else if workflowRun.WorkflowId != workflow.Id {
|
||||
return errors.New("workflow run not found")
|
||||
} else if workflowRun.Status != domain.WorkflowRunStatusTypePending && workflowRun.Status != domain.WorkflowRunStatusTypeRunning {
|
||||
return errors.New("workflow run is not pending or running")
|
||||
return nil, errors.New("workflow run not found")
|
||||
} else if workflowRun.Status != domain.WorkflowRunStatusTypePending && workflowRun.Status != domain.WorkflowRunStatusTypeProcessing {
|
||||
return nil, errors.New("workflow run is not pending or processing")
|
||||
}
|
||||
|
||||
s.dispatcher.Cancel(workflowRun.Id)
|
||||
s.dispatcher.Cancel(ctx, workflowRun.Id)
|
||||
|
||||
return nil
|
||||
return &dtos.WorkflowCancelRunResp{}, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowService) Shutdown(ctx context.Context) {
|
||||
s.dispatcher.Shutdown()
|
||||
s.dispatcher.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (s *WorkflowService) cleanupHistoryRuns(ctx context.Context) error {
|
||||
settings, err := s.settingsRepo.GetByName(ctx, "persistence")
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to get persistence settings", slog.Any("error", err))
|
||||
return err
|
||||
}
|
||||
|
||||
persistenceSettings, _ := settings.UnmarshalContentAsPersistence()
|
||||
if persistenceSettings != nil && persistenceSettings.WorkflowRunsMaxDaysRetention != 0 {
|
||||
ret, err := s.workflowRunRepo.DeleteWhere(
|
||||
ctx,
|
||||
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))),
|
||||
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeProcessing))),
|
||||
dbx.NewExp(fmt.Sprintf("endedAt<DATETIME('now', '-%d days')", persistenceSettings.WorkflowRunsMaxDaysRetention)),
|
||||
)
|
||||
if err != nil {
|
||||
app.GetLogger().Error("failed to delete workflow history runs", slog.Any("error", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if ret > 0 {
|
||||
app.GetLogger().Info(fmt.Sprintf("cleanup %d workflow history runs", ret))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/domain"
|
||||
)
|
||||
|
||||
type workflowRepository interface {
|
||||
ListEnabledScheduled(ctx context.Context) ([]*domain.Workflow, error)
|
||||
GetById(ctx context.Context, id string) (*domain.Workflow, error)
|
||||
Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error)
|
||||
}
|
||||
|
||||
type workflowRunRepository interface {
|
||||
GetById(ctx context.Context, id string) (*domain.WorkflowRun, error)
|
||||
Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
SaveWithCascading(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
|
||||
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
|
||||
}
|
||||
|
||||
type settingsRepository interface {
|
||||
GetByName(ctx context.Context, name string) (*domain.Settings, error)
|
||||
}
|
||||
@@ -68,6 +68,6 @@ func main() {
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
slog.Error("[CERTIMATE] Start failed.", "err", err)
|
||||
slog.Error("[CERTIMATE] Start failed.", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
tracer := NewTracer("v0.4.0")
|
||||
tracer.Printf("go ...")
|
||||
|
||||
// update collection `access`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
records, err := app.FindAllRecords(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
changed := false
|
||||
|
||||
provider := record.GetString("provider")
|
||||
config := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("config", &config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "discordbot", "mattermost", "slackbot":
|
||||
if _, ok := config["defaultChannelId"]; ok {
|
||||
config["channelId"] = config["defaultChannelId"]
|
||||
delete(config, "defaultChannelId")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case "email":
|
||||
if _, ok := config["defaultSenderAddress"]; ok {
|
||||
config["senderAddress"] = config["defaultSenderAddress"]
|
||||
delete(config, "defaultSenderAddress")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := config["defaultSenderName"]; ok {
|
||||
config["senderName"] = config["defaultSenderName"]
|
||||
delete(config, "defaultSenderName")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := config["defaultReceiverAddress"]; ok {
|
||||
config["receiverAddress"] = config["defaultReceiverAddress"]
|
||||
delete(config, "defaultReceiverAddress")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case "telegrambot":
|
||||
if _, ok := config["defaultChatId"]; ok {
|
||||
config["chatId"] = config["defaultChatId"]
|
||||
delete(config, "defaultChatId")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
|
||||
case "webhook":
|
||||
if _, ok := config["defaultDataForDeployment"]; ok {
|
||||
config["dataForDeployment"] = config["defaultDataForDeployment"]
|
||||
delete(config, "defaultDataForDeployment")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := config["defaultDataForNotification"]; ok {
|
||||
config["dataForNotification"] = config["defaultDataForNotification"]
|
||||
delete(config, "defaultDataForNotification")
|
||||
record.Set("config", config)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update collection `certificate`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("4szxr9x43tpj6np")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "by9hetqi",
|
||||
"maxSelect": 1,
|
||||
"name": "source",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"request",
|
||||
"upload"
|
||||
]
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "v40aqzpd",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "validityNotBefore",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(10, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "zgpdby2k",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "validityNotAfter",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(15, []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "tovyif5ax6j62ur",
|
||||
"hidden": false,
|
||||
"id": "uvqfamb1",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(16, []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "qjp8lygssgwyqyz",
|
||||
"hidden": false,
|
||||
"id": "relation3917999135",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowRunRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(17, []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "bqnxb95f2cooowp",
|
||||
"hidden": false,
|
||||
"id": "2ohlr0yd",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowOutputRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": [
|
||||
"CREATE INDEX `+"`"+`idx_Jx8TXzDCmw`+"`"+` ON `+"`"+`certificate`+"`"+` (`+"`"+`workflowRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_2cRXqNDyyp`+"`"+` ON `+"`"+`certificate`+"`"+` (`+"`"+`workflowRunRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_kcKpgAZapk`+"`"+` ON `+"`"+`certificate`+"`"+` (`+"`"+`workflowNodeId`+"`"+`)"
|
||||
]
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := app.DB().NewQuery("UPDATE certificate SET source = 'request' WHERE source = 'workflow'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("collection '%s' updated", collection.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// update collection `workflow`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "vqoajwjq",
|
||||
"maxSelect": 1,
|
||||
"name": "trigger",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"manual",
|
||||
"scheduled"
|
||||
]
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "qjp8lygssgwyqyz",
|
||||
"hidden": false,
|
||||
"id": "a23wkj9x",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "lastRunRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow SET trigger = 'scheduled' WHERE trigger = 'auto'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("collection '%s' updated", collection.Name)
|
||||
|
||||
records, err := app.FindAllRecords(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, record := range records {
|
||||
changed := false
|
||||
|
||||
draft := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("draft", &draft); err == nil {
|
||||
if _, ok := draft["config"]; ok {
|
||||
config := draft["config"].(map[string]any)
|
||||
if _, ok := config["trigger"]; ok {
|
||||
trigger := config["trigger"].(string)
|
||||
if trigger == "auto" {
|
||||
config["trigger"] = "scheduled"
|
||||
record.Set("draft", draft)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("content", &content); err == nil {
|
||||
if _, ok := content["config"]; ok {
|
||||
config := content["config"].(map[string]any)
|
||||
if _, ok := config["trigger"]; ok {
|
||||
trigger := config["trigger"].(string)
|
||||
if trigger == "auto" {
|
||||
config["trigger"] = "scheduled"
|
||||
record.Set("content", content)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update collection `workflow_run`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "tovyif5ax6j62ur",
|
||||
"hidden": false,
|
||||
"id": "m8xfsyyy",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "jlroa3fk",
|
||||
"maxSelect": 1,
|
||||
"name": "trigger",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"manual",
|
||||
"scheduled"
|
||||
]
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": [
|
||||
"CREATE INDEX `+"`"+`idx_7ZpfjTFsD2`+"`"+` ON `+"`"+`workflow_run`+"`"+` (`+"`"+`workflowRef`+"`"+`)"
|
||||
]
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow_run SET trigger = 'scheduled' WHERE trigger = 'auto'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("collection '%s' updated", collection.Name)
|
||||
|
||||
records, err := app.FindAllRecords(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
for _, record := range records {
|
||||
changed := false
|
||||
|
||||
detail := make(map[string]any)
|
||||
if err := record.UnmarshalJSONField("detail", &detail); err == nil {
|
||||
if _, ok := detail["config"]; ok {
|
||||
config := detail["config"].(map[string]any)
|
||||
if _, ok := config["trigger"]; ok {
|
||||
trigger := config["trigger"].(string)
|
||||
if trigger == "auto" {
|
||||
config["trigger"] = "scheduled"
|
||||
record.Set("detail", detail)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update collection `workflow_output`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("bqnxb95f2cooowp")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": [
|
||||
"CREATE INDEX `+"`"+`idx_BYoQPsz4my`+"`"+` ON `+"`"+`workflow_output`+"`"+` (`+"`"+`workflowRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_O9zxLETuxJ`+"`"+` ON `+"`"+`workflow_output`+"`"+` (`+"`"+`runRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_luac8Ul34G`+"`"+` ON `+"`"+`workflow_output`+"`"+` (`+"`"+`nodeId`+"`"+`)"
|
||||
]
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "tovyif5ax6j62ur",
|
||||
"hidden": false,
|
||||
"id": "jka88auc",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "qjp8lygssgwyqyz",
|
||||
"hidden": false,
|
||||
"id": "relation821863227",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "runRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("collection '%s' updated", collection.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// update collection `workflow_logs`
|
||||
{
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_1682296116")
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collection != nil {
|
||||
if field := collection.Fields.GetByName("level"); field != nil && field.Type() == "text" {
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = -4 WHERE level = 'DEBUG'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 0 WHERE level = 'INFO'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 4 WHERE level = 'WARN'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := app.DB().NewQuery("UPDATE workflow_logs SET level = 8 WHERE level = 'ERROR'").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number760395071",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "levelTmp",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Fields.RemoveById(field.GetId())
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number760395071",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "level",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(1, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "tovyif5ax6j62ur",
|
||||
"hidden": false,
|
||||
"id": "relation3371272342",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "workflowRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "qjp8lygssgwyqyz",
|
||||
"hidden": false,
|
||||
"id": "relation821863227",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "runRef",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": [
|
||||
"CREATE INDEX `+"`"+`idx_IOlpy6XuJ2`+"`"+` ON `+"`"+`workflow_logs`+"`"+` (`+"`"+`workflowRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_qVlTb2yl7v`+"`"+` ON `+"`"+`workflow_logs`+"`"+` (`+"`"+`runRef`+"`"+`)",
|
||||
"CREATE INDEX `+"`"+`idx_UL4tdCXNlA`+"`"+` ON `+"`"+`workflow_logs`+"`"+` (`+"`"+`nodeId`+"`"+`)"
|
||||
]
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracer.Printf("collection '%s' updated", collection.Name)
|
||||
}
|
||||
}
|
||||
|
||||
tracer.Printf("done")
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ func NewHookHandler(opts *HookHandlerOptions) *HookHandler {
|
||||
}
|
||||
|
||||
if h.options.WriteFunc == nil {
|
||||
panic("`options.WriteFunc` is nil")
|
||||
panic("the `options.WriteFunc` is nil")
|
||||
}
|
||||
|
||||
if h.options.Level == nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// - err: 错误。
|
||||
func ConvertCertificateToPEM(cert *x509.Certificate) (_certPEM string, _err error) {
|
||||
if cert == nil {
|
||||
return "", errors.New("`cert` is nil")
|
||||
return "", errors.New("the input certificate is nil")
|
||||
}
|
||||
|
||||
block := &pem.Block{
|
||||
@@ -39,7 +39,7 @@ func ConvertCertificateToPEM(cert *x509.Certificate) (_certPEM string, _err erro
|
||||
// - err: 错误。
|
||||
func ConvertECPrivateKeyToPEM(privkey *ecdsa.PrivateKey) (_privkeyPEM string, _err error) {
|
||||
if privkey == nil {
|
||||
return "", errors.New("`privkey` is nil")
|
||||
return "", errors.New("the input private key is nil")
|
||||
}
|
||||
|
||||
data, _err := x509.MarshalECPrivateKey(privkey)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Certimate - Your Trusted SSL Automation Partner</title>
|
||||
<title>Certimate - Your Trusted Partner in SSL Automation</title>
|
||||
</head>
|
||||
<body style="pointer-events: auto !important">
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+354
-344
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -11,22 +11,22 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.2",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@flowgram.ai/document": "^0.3.5",
|
||||
"@flowgram.ai/fixed-layout-editor": "^0.3.5",
|
||||
"@flowgram.ai/minimap-plugin": "^0.3.5",
|
||||
"@flowgram.ai/document": "^0.4.0",
|
||||
"@flowgram.ai/fixed-layout-editor": "^0.4.0",
|
||||
"@flowgram.ai/minimap-plugin": "^0.4.0",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@uiw/codemirror-extensions-basic-setup": "^4.24.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.24.2",
|
||||
"@uiw/react-codemirror": "^4.24.2",
|
||||
"@uiw/codemirror-extensions-basic-setup": "^4.25.1",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.1",
|
||||
"@uiw/react-codemirror": "^4.25.1",
|
||||
"ahooks": "^3.9.0",
|
||||
"antd": "^5.27.0",
|
||||
"antd-zod": "^7.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cron-parser": "^5.3.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^25.3.4",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"immer": "^10.1.1",
|
||||
"nanoid": "^5.1.5",
|
||||
@@ -36,15 +36,15 @@
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.6.1",
|
||||
"react-router-dom": "^7.8.0",
|
||||
"react-router-dom": "^7.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.0.17",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.16.5",
|
||||
@@ -63,7 +63,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"fs-extra": "^11.3.1",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^6.3.5"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { basicSetup } from "@uiw/codemirror-extensions-basic-setup";
|
||||
import { vscodeDark, vscodeLight } from "@uiw/codemirror-theme-vscode";
|
||||
import CodeMirror, { type ReactCodeMirrorProps, type ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
||||
import { useFocusWithin } from "ahooks";
|
||||
import { useFocusWithin, useHover } from "ahooks";
|
||||
import { theme } from "antd";
|
||||
|
||||
import { useBrowserTheme } from "@/hooks";
|
||||
@@ -24,7 +24,8 @@ const CodeInput = ({ className, style, disabled, language, ...props }: CodeInput
|
||||
const { theme: browserTheme } = useBrowserTheme();
|
||||
|
||||
const cmRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const isFocusWithin = useFocusWithin(cmRef.current?.editor);
|
||||
const isFocusing = useFocusWithin(cmRef.current?.editor);
|
||||
const isHovering = useHover(cmRef.current?.editor);
|
||||
|
||||
const cmTheme = useMemo(() => {
|
||||
if (browserTheme === "dark") {
|
||||
@@ -66,13 +67,30 @@ const CodeInput = ({ className, style, disabled, language, ...props }: CodeInput
|
||||
|
||||
return (
|
||||
<div
|
||||
className={mergeCls(className, `hover:border-[${themeToken.colorPrimaryBorderHover}]`)}
|
||||
className={mergeCls("ant-input", className)}
|
||||
style={{
|
||||
...(style ?? {}),
|
||||
border: `1px solid ${isFocusWithin ? (themeToken.Input?.activeBorderColor ?? themeToken.colorPrimaryBorder) : themeToken.colorBorder}`,
|
||||
...style,
|
||||
paddingBlock: themeToken.Input?.paddingBlock,
|
||||
paddingInline: themeToken.Input?.paddingInline,
|
||||
fontSize: themeToken.Input?.inputFontSize,
|
||||
lineHeight: themeToken.lineHeight,
|
||||
color: disabled ? themeToken.colorTextDisabled : themeToken.colorText,
|
||||
backgroundColor: disabled
|
||||
? themeToken.colorBgContainerDisabled
|
||||
: isFocusing
|
||||
? (themeToken.Input?.activeBg ?? themeToken.colorBgContainer)
|
||||
: isHovering
|
||||
? (themeToken.Input?.hoverBg ?? themeToken.colorBgContainer)
|
||||
: void 0,
|
||||
borderWidth: `${themeToken.lineWidth}px`,
|
||||
borderStyle: themeToken.lineType,
|
||||
borderColor: isFocusing
|
||||
? (themeToken.Input?.activeBorderColor ?? themeToken.colorPrimaryActive)
|
||||
: isHovering
|
||||
? (themeToken.Input?.hoverBorderColor ?? themeToken.colorPrimaryHover)
|
||||
: themeToken.colorBorder,
|
||||
borderRadius: `${themeToken.borderRadius}px`,
|
||||
backgroundColor: disabled ? themeToken.colorBgContainerDisabled : themeToken.colorBgContainer,
|
||||
boxShadow: isFocusWithin ? themeToken.Input?.activeShadow : void 0,
|
||||
boxShadow: isFocusing ? themeToken.Input?.activeShadow : void 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -58,7 +58,7 @@ const DrawerForm = <T extends NonNullable<unknown> = any>({
|
||||
form: formInst,
|
||||
formPending,
|
||||
formProps,
|
||||
submit,
|
||||
submit: submitForm,
|
||||
} = useAntdForm({
|
||||
form,
|
||||
onSubmit: (values) => {
|
||||
@@ -75,25 +75,22 @@ const DrawerForm = <T extends NonNullable<unknown> = any>({
|
||||
const mergedDrawerProps: DrawerProps = {
|
||||
...drawerProps,
|
||||
closeIcon: false,
|
||||
afterOpenChange: (open) => {
|
||||
if (!open && !mergedFormProps.preserve) {
|
||||
formInst.resetFields();
|
||||
}
|
||||
|
||||
drawerProps?.afterOpenChange?.(open);
|
||||
},
|
||||
onClose: async (e) => {
|
||||
if (formPending) return;
|
||||
|
||||
// 关闭 Drawer 时 Promise.reject 阻止关闭
|
||||
await drawerProps?.onClose?.(e);
|
||||
setOpen(false);
|
||||
|
||||
if (!mergedFormProps.preserve) {
|
||||
formInst.resetFields();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleOkClick = async () => {
|
||||
// 提交表单返回 Promise.reject 时不关闭 Drawer
|
||||
await submit();
|
||||
await submitForm();
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useControllableValue } from "ahooks";
|
||||
@@ -12,6 +12,8 @@ import { getErrMsg } from "@/utils/error";
|
||||
import AccessForm, { type AccessFormInstance, type AccessFormProps } from "./AccessForm";
|
||||
|
||||
export interface AccessEditDrawerProps {
|
||||
afterClose?: () => void;
|
||||
afterSubmit?: (record: AccessModel) => void;
|
||||
data?: AccessFormProps["initialValues"];
|
||||
loading?: boolean;
|
||||
mode: AccessFormProps["mode"];
|
||||
@@ -19,10 +21,9 @@ export interface AccessEditDrawerProps {
|
||||
trigger?: React.ReactNode;
|
||||
usage?: AccessFormProps["usage"];
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
afterSubmit?: (record: AccessModel) => void;
|
||||
}
|
||||
|
||||
const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, ...props }: AccessEditDrawerProps) => {
|
||||
const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigger, usage, ...props }: AccessEditDrawerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { notification } = App.useApp();
|
||||
@@ -99,7 +100,8 @@ const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, ..
|
||||
{triggerEl}
|
||||
|
||||
<Drawer
|
||||
afterOpenChange={setOpen}
|
||||
afterOpenChange={(open) => !open && afterClose?.()}
|
||||
autoFocus
|
||||
closeIcon={false}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
@@ -141,29 +143,41 @@ const AccessEditDrawer = ({ mode, data, loading, trigger, usage, afterSubmit, ..
|
||||
);
|
||||
};
|
||||
|
||||
const useProps = () => {
|
||||
const [data, setData] = useState<AccessEditDrawerProps["data"]>();
|
||||
const useDrawer = () => {
|
||||
type DataType = AccessEditDrawerProps["data"];
|
||||
const [data, setData] = useState<DataType>();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setData(void 0);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
open,
|
||||
setData,
|
||||
setOpen,
|
||||
onOpenChange,
|
||||
drawerProps: {
|
||||
afterClose: () => {
|
||||
startTransition(() => {
|
||||
if (!open) {
|
||||
setData(void 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
data,
|
||||
open,
|
||||
onOpenChange,
|
||||
},
|
||||
|
||||
open: (data: NonNullable<DataType>) => {
|
||||
setData(data);
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _default = Object.assign(AccessEditDrawer, {
|
||||
useProps,
|
||||
useDrawer,
|
||||
});
|
||||
|
||||
export default _default;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCircleArrowDown, IconCircleArrowUp, IconCircleMinus, IconCirclePlus } from "@tabler/icons-react";
|
||||
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Select } from "antd";
|
||||
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Radio } from "antd";
|
||||
import { createSchemaFieldRule } from "antd-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -135,13 +135,12 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
|
||||
</div>
|
||||
|
||||
<Form.Item name="authMethod" label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
|
||||
<Select
|
||||
<Radio.Group
|
||||
options={[AUTH_METHOD_NONE, AUTH_METHOD_PASSWORD, AUTH_METHOD_KEY].map((s) => ({
|
||||
key: s,
|
||||
label: t(`access.form.ssh_auth_method.option.${s}.label`),
|
||||
value: s,
|
||||
}))}
|
||||
placeholder={t("access.form.ssh_auth_method.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -201,17 +200,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
|
||||
</div>
|
||||
|
||||
<Form.Item name={[field.name, "authMethod"]} label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
|
||||
<Select placeholder={t("access.form.ssh_auth_method.placeholder")}>
|
||||
<Select.Option key={AUTH_METHOD_NONE} value={AUTH_METHOD_NONE}>
|
||||
{t("access.form.ssh_auth_method.option.none.label")}
|
||||
</Select.Option>
|
||||
<Select.Option key={AUTH_METHOD_PASSWORD} value={AUTH_METHOD_PASSWORD}>
|
||||
{t("access.form.ssh_auth_method.option.password.label")}
|
||||
</Select.Option>
|
||||
<Select.Option key={AUTH_METHOD_KEY} value={AUTH_METHOD_KEY}>
|
||||
{t("access.form.ssh_auth_method.option.key.label")}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Radio.Group
|
||||
options={[AUTH_METHOD_NONE, AUTH_METHOD_PASSWORD, AUTH_METHOD_KEY].map((s) => ({
|
||||
key: s,
|
||||
label: t(`access.form.ssh_auth_method.option.${s}.label`),
|
||||
value: s,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={[field.name, "username"]} label={t("access.form.ssh_username.label")} rules={[formRule]}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { Avatar, Select, type SelectProps, Typography, theme } from "antd";
|
||||
|
||||
import { type AccessModel } from "@/domain/access";
|
||||
@@ -15,9 +16,7 @@ const AccessSelect = ({ onFilter, ...props }: AccessTypeSelectProps) => {
|
||||
const { token: themeToken } = theme.useToken();
|
||||
|
||||
const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"]));
|
||||
useEffect(() => {
|
||||
fetchAccesses(false);
|
||||
}, []);
|
||||
useAsyncEffect(() => fetchAccesses(false), []);
|
||||
|
||||
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: AccessModel }>>([]);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { startTransition, useCallback, useState } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useControllableValue } from "ahooks";
|
||||
import { Button, Drawer, Flex } from "antd";
|
||||
@@ -9,6 +9,7 @@ import { useTriggerElement } from "@/hooks";
|
||||
import CertificateDetail from "./CertificateDetail";
|
||||
|
||||
export interface CertificateDetailDrawerProps {
|
||||
afterClose?: () => void;
|
||||
data?: CertificateModel;
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
@@ -16,7 +17,7 @@ export interface CertificateDetailDrawerProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: CertificateDetailDrawerProps) => {
|
||||
const CertificateDetailDrawer = ({ afterClose, data, loading, trigger, ...props }: CertificateDetailDrawerProps) => {
|
||||
const [open, setOpen] = useControllableValue<boolean>(props, {
|
||||
valuePropName: "open",
|
||||
defaultValuePropName: "defaultOpen",
|
||||
@@ -30,7 +31,8 @@ const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: Certifica
|
||||
{triggerEl}
|
||||
|
||||
<Drawer
|
||||
afterOpenChange={setOpen}
|
||||
afterOpenChange={(open) => !open && afterClose?.()}
|
||||
autoFocus
|
||||
closeIcon={false}
|
||||
destroyOnHidden
|
||||
open={open}
|
||||
@@ -60,29 +62,41 @@ const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: Certifica
|
||||
);
|
||||
};
|
||||
|
||||
const useProps = () => {
|
||||
const [data, setData] = useState<CertificateDetailDrawerProps["data"]>();
|
||||
const useDrawer = () => {
|
||||
type DataType = CertificateDetailDrawerProps["data"];
|
||||
const [data, setData] = useState<DataType>();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setData(void 0);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
open,
|
||||
setData,
|
||||
setOpen,
|
||||
onOpenChange,
|
||||
drawerProps: {
|
||||
afterClose: () => {
|
||||
startTransition(() => {
|
||||
if (!open) {
|
||||
setData(void 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
data,
|
||||
open,
|
||||
onOpenChange,
|
||||
},
|
||||
|
||||
open: (data: NonNullable<DataType>) => {
|
||||
setData(data);
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _default = Object.assign(CertificateDetailDrawer, {
|
||||
useProps,
|
||||
useDrawer,
|
||||
});
|
||||
|
||||
export default _default;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSize } from "ahooks";
|
||||
import { useAsyncEffect, useSize } from "ahooks";
|
||||
import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd";
|
||||
|
||||
import Show from "@/components/Show";
|
||||
@@ -23,9 +23,7 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle",
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
|
||||
useEffect(() => {
|
||||
fetchAccesses(false);
|
||||
}, []);
|
||||
useAsyncEffect(() => fetchAccesses(false), []);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperSize = useSize(wrapperRef);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
|
||||
|
||||
import ApplyNode from "./node/ApplyNode";
|
||||
import BranchNode from "./node/BranchNode";
|
||||
import ConditionNode from "./node/ConditionNode";
|
||||
import DeployNode from "./node/DeployNode";
|
||||
import EndNode from "./node/EndNode";
|
||||
import ExecuteResultBranchNode from "./node/ExecuteResultBranchNode";
|
||||
import ExecuteResultNode from "./node/ExecuteResultNode";
|
||||
import MonitorNode from "./node/MonitorNode";
|
||||
import NotifyNode from "./node/NotifyNode";
|
||||
import StartNode from "./node/StartNode";
|
||||
import UnknownNode from "./node/UnknownNode";
|
||||
import UploadNode from "./node/UploadNode";
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export interface WorkflowElementProps {
|
||||
node: WorkflowNode;
|
||||
disabled?: boolean;
|
||||
branchId?: string;
|
||||
branchIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElementProps) => {
|
||||
const nodeEl = useMemo(() => {
|
||||
switch (node.type) {
|
||||
case WorkflowNodeType.Start:
|
||||
return <StartNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Apply:
|
||||
return <ApplyNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Upload:
|
||||
return <UploadNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Monitor:
|
||||
return <MonitorNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Deploy:
|
||||
return <DeployNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Notify:
|
||||
return <NotifyNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Branch:
|
||||
return <BranchNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.ExecuteResultBranch:
|
||||
return <ExecuteResultBranchNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.ExecuteSuccess:
|
||||
case WorkflowNodeType.ExecuteFailure:
|
||||
return <ExecuteResultNode node={node} disabled={disabled} branchId={branchId!} branchIndex={branchIndex!} />;
|
||||
|
||||
case WorkflowNodeType.Condition:
|
||||
return <ConditionNode node={node} disabled={disabled} branchId={branchId!} branchIndex={branchIndex!} />;
|
||||
|
||||
case WorkflowNodeType.End:
|
||||
return <EndNode />;
|
||||
|
||||
default:
|
||||
console.warn(`[certimate] unsupported workflow node type: '${node.type}'`);
|
||||
return <UnknownNode node={node} />;
|
||||
}
|
||||
}, [node, disabled, branchId, branchIndex]);
|
||||
|
||||
return <>{nodeEl}</>;
|
||||
};
|
||||
|
||||
export default memo(WorkflowElement);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import WorkflowElement from "@/components/workflow/WorkflowElement";
|
||||
import { WorkflowNodeType, newNode } from "@/domain/workflow";
|
||||
import { useZustandShallowSelector } from "@/hooks";
|
||||
import { useWorkflowStore } from "@/stores/workflow";
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export interface WorkflowElementsProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
const WorkflowElements = ({ className, style, disabled }: WorkflowElementsProps) => {
|
||||
const { workflow } = useWorkflowStore(useZustandShallowSelector(["workflow"]));
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const root = workflow.draft;
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
let current = root as typeof root | undefined;
|
||||
while (current) {
|
||||
nodes.push(<WorkflowElement key={current.id} node={current} disabled={disabled} />);
|
||||
current = current.next;
|
||||
}
|
||||
|
||||
nodes.push(<WorkflowElement key="end" node={newNode(WorkflowNodeType.End)} />);
|
||||
|
||||
return nodes;
|
||||
}, [workflow, disabled]);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className="flex w-max min-w-full flex-col items-center">{elements}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowElements;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { IconMaximize, IconMinus, IconPlus } from "@tabler/icons-react";
|
||||
import { Button, Card, Typography } from "antd";
|
||||
|
||||
import WorkflowElements from "@/components/workflow/WorkflowElements";
|
||||
import { mergeCls } from "@/utils/css";
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export interface WorkflowElementsProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
const WorkflowElementsContainer = ({ className, style, disabled }: WorkflowElementsProps) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const MIN_SCALE = 0.2;
|
||||
const MAX_SCALE = 2;
|
||||
const STEP_SCALE = 0.05;
|
||||
|
||||
return (
|
||||
<div className={mergeCls("relative size-full overflow-hidden", className)} style={style}>
|
||||
<div className="size-full overflow-auto">
|
||||
<div className="relative z-1">
|
||||
<div className="origin-center transition-transform duration-300" style={{ zoom: `${scale}` }}>
|
||||
<div className="p-4">
|
||||
<WorkflowElements disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 z-2 w-full px-4">
|
||||
<div className="container">
|
||||
<div className="flex justify-end">
|
||||
<Card className="rounded-lg p-2 shadow-lg" styles={{ body: { padding: 0 } }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<IconMinus size="1.25em" />} disabled={scale <= MIN_SCALE} onClick={() => setScale((s) => Math.max(MIN_SCALE, s - STEP_SCALE))} />
|
||||
<Typography.Text className="min-w-12 text-center">{Math.round(scale * 100)}%</Typography.Text>
|
||||
<Button icon={<IconPlus size="1.25em" />} disabled={scale >= MAX_SCALE} onClick={() => setScale((s) => Math.min(MAX_SCALE, s + STEP_SCALE))} />
|
||||
<Button icon={<IconMaximize size="1.25em" />} onClick={() => setScale(1)} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowElementsContainer;
|
||||
@@ -54,7 +54,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin
|
||||
},
|
||||
{
|
||||
refreshDeps: [runId, runStatus],
|
||||
pollingInterval: runStatus === WORKFLOW_RUN_STATUSES.PENDING || runStatus === WORKFLOW_RUN_STATUSES.RUNNING ? 3000 : 0,
|
||||
pollingInterval: runStatus === WORKFLOW_RUN_STATUSES.PENDING || runStatus === WORKFLOW_RUN_STATUSES.PROCESSING ? 3000 : 0,
|
||||
pollingWhenHidden: false,
|
||||
throttleWait: 500,
|
||||
onSuccess: (res) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { startTransition, useCallback, useState } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useControllableValue } from "ahooks";
|
||||
import { Button, Drawer, Flex } from "antd";
|
||||
@@ -10,6 +10,7 @@ import { useTriggerElement } from "@/hooks";
|
||||
import WorkflowRunDetail from "./WorkflowRunDetail";
|
||||
|
||||
export interface WorkflowRunDetailDrawerProps {
|
||||
afterClose?: () => void;
|
||||
data?: WorkflowRunModel;
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
@@ -17,7 +18,7 @@ export interface WorkflowRunDetailDrawerProps {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowRunDetailDrawerProps) => {
|
||||
const WorkflowRunDetailDrawer = ({ afterClose, data, loading, trigger, ...props }: WorkflowRunDetailDrawerProps) => {
|
||||
const [open, setOpen] = useControllableValue<boolean>(props, {
|
||||
valuePropName: "open",
|
||||
defaultValuePropName: "defaultOpen",
|
||||
@@ -31,7 +32,7 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR
|
||||
{triggerEl}
|
||||
|
||||
<Drawer
|
||||
afterOpenChange={setOpen}
|
||||
afterOpenChange={(open) => !open && afterClose?.()}
|
||||
closeIcon={false}
|
||||
destroyOnHidden
|
||||
open={open}
|
||||
@@ -61,29 +62,41 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR
|
||||
);
|
||||
};
|
||||
|
||||
const useProps = () => {
|
||||
const [data, setData] = useState<WorkflowRunDetailDrawerProps["data"]>();
|
||||
const useDrawer = () => {
|
||||
type DataType = WorkflowRunDetailDrawerProps["data"];
|
||||
const [data, setData] = useState<DataType>();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setData(undefined);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
open,
|
||||
setData,
|
||||
setOpen,
|
||||
onOpenChange,
|
||||
drawerProps: {
|
||||
afterClose: () => {
|
||||
startTransition(() => {
|
||||
if (!open) {
|
||||
setData(void 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
data,
|
||||
open,
|
||||
onOpenChange,
|
||||
},
|
||||
|
||||
open: (data: NonNullable<DataType>) => {
|
||||
setData(data);
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _default = Object.assign(WorkflowRunDetailDrawer, {
|
||||
useProps,
|
||||
useDrawer,
|
||||
});
|
||||
|
||||
export default _default;
|
||||
|
||||
@@ -24,7 +24,7 @@ const useColor = (value: WorkflorRunStatusType | string, defaultColor?: string |
|
||||
return themeToken.colorTextSecondary;
|
||||
}
|
||||
break;
|
||||
case WORKFLOW_RUN_STATUSES.RUNNING:
|
||||
case WORKFLOW_RUN_STATUSES.PROCESSING:
|
||||
if (defaultColor == null || !defaultColor) {
|
||||
return themeToken.colorInfo;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ const WorkflowStatusIcon = ({ className, style, size = "1.25em", type = "outline
|
||||
{type === "filled" ? <IconClockFilled color={color} size={size} /> : <IconClock color={color} size={size} />}
|
||||
</span>
|
||||
);
|
||||
case WORKFLOW_RUN_STATUSES.RUNNING:
|
||||
case WORKFLOW_RUN_STATUSES.PROCESSING:
|
||||
return (
|
||||
<span className={mergeCls("anticon", "animate-spin", className)} style={style} role="img">
|
||||
<IconLoader3 color={color} size={size} />
|
||||
@@ -125,7 +125,7 @@ const WorkflowStatus = ({ className, style, children, showIcon = true, type, val
|
||||
|
||||
switch (value) {
|
||||
case WORKFLOW_RUN_STATUSES.PENDING:
|
||||
case WORKFLOW_RUN_STATUSES.RUNNING:
|
||||
case WORKFLOW_RUN_STATUSES.PROCESSING:
|
||||
case WORKFLOW_RUN_STATUSES.SUCCEEDED:
|
||||
case WORKFLOW_RUN_STATUSES.FAILED:
|
||||
case WORKFLOW_RUN_STATUSES.CANCELED:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import {
|
||||
ConstantKeys,
|
||||
EditorRenderer,
|
||||
EditorState,
|
||||
FixedLayoutEditorProvider,
|
||||
type FixedLayoutPluginContext,
|
||||
type FixedLayoutProps,
|
||||
@@ -27,6 +28,8 @@ export interface DesignerProps {
|
||||
children?: React.ReactNode;
|
||||
initialData?: FlowDocumentJSON;
|
||||
readonly?: boolean;
|
||||
onDocumentChange?: (ctx: FixedLayoutPluginContext) => void;
|
||||
onNodeChange?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void;
|
||||
onNodeClick?: (ctx: FixedLayoutPluginContext, node: FlowNodeEntity) => void;
|
||||
}
|
||||
|
||||
@@ -35,149 +38,181 @@ export interface DesignerInstance extends FixedLayoutPluginContext {
|
||||
validateAllNodes(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const Designer = forwardRef<DesignerInstance, DesignerProps>(({ className, style, children, initialData, readonly, onNodeClick }, ref) => {
|
||||
const { token: themeToken } = theme.useToken();
|
||||
const Designer = forwardRef<DesignerInstance, DesignerProps>(
|
||||
({ className, style, children, initialData, readonly, onDocumentChange, onNodeChange, onNodeClick }, ref) => {
|
||||
const { token: themeToken } = theme.useToken();
|
||||
|
||||
const flowgramEditorRef = useRef<FixedLayoutPluginContext>(null);
|
||||
const flowgramEditorProps = useMemo<FixedLayoutProps>(
|
||||
() => ({
|
||||
initialData: initialData,
|
||||
const rendered = useRef(false);
|
||||
|
||||
constants: {
|
||||
[ConstantKeys.BASE_COLOR]: themeToken.colorBorder,
|
||||
[ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary,
|
||||
[ConstantKeys.NODE_SPACING]: 48,
|
||||
[ConstantKeys.BRANCH_SPACING]: 48,
|
||||
},
|
||||
const flowgramEditorRef = useRef<FixedLayoutPluginContext>(null);
|
||||
const flowgramEditorProps = useMemo<FixedLayoutProps>(
|
||||
() => ({
|
||||
initialData: initialData,
|
||||
|
||||
background: {
|
||||
backgroundColor: themeToken.colorBgContainer,
|
||||
dotSize: 0,
|
||||
},
|
||||
|
||||
playground: {
|
||||
autoFocus: true,
|
||||
autoResize: true,
|
||||
preventGlobalGesture: true,
|
||||
},
|
||||
|
||||
selectBox: {
|
||||
enable: false,
|
||||
},
|
||||
|
||||
scroll: {
|
||||
enableScrollLimit: true,
|
||||
},
|
||||
|
||||
readonly: readonly,
|
||||
|
||||
nodeEngine: {
|
||||
enable: true,
|
||||
},
|
||||
|
||||
variableEngine: {
|
||||
enable: true,
|
||||
},
|
||||
|
||||
materials: {
|
||||
components: getAllElements(),
|
||||
renderTexts: {
|
||||
[FlowTextKey.TRY_START_TEXT]: "Try",
|
||||
[FlowTextKey.TRY_END_TEXT]: "Then",
|
||||
[FlowTextKey.CATCH_TEXT]: "Catch",
|
||||
constants: {
|
||||
[ConstantKeys.BASE_COLOR]: themeToken.colorBorder,
|
||||
[ConstantKeys.BASE_ACTIVATED_COLOR]: themeToken.colorPrimary,
|
||||
[ConstantKeys.NODE_SPACING]: 48,
|
||||
[ConstantKeys.BRANCH_SPACING]: 48,
|
||||
},
|
||||
renderDefaultNode: NodeRender,
|
||||
},
|
||||
|
||||
nodeRegistries: getAllNodeRegistries(),
|
||||
background: {
|
||||
backgroundColor: themeToken.colorBgContainer,
|
||||
dotSize: 0,
|
||||
},
|
||||
|
||||
getNodeDefaultRegistry(type) {
|
||||
return {
|
||||
type,
|
||||
meta: {
|
||||
defaultExpanded: true,
|
||||
playground: {
|
||||
autoFocus: true,
|
||||
autoResize: true,
|
||||
preventGlobalGesture: true,
|
||||
},
|
||||
|
||||
selectBox: {
|
||||
enable: false,
|
||||
},
|
||||
|
||||
scroll: {
|
||||
enableScrollLimit: true,
|
||||
},
|
||||
|
||||
readonly: readonly,
|
||||
|
||||
nodeEngine: {
|
||||
enable: true,
|
||||
},
|
||||
|
||||
variableEngine: {
|
||||
enable: true,
|
||||
},
|
||||
|
||||
materials: {
|
||||
components: getAllElements(),
|
||||
renderTexts: {
|
||||
[FlowTextKey.TRY_START_TEXT]: "Try",
|
||||
[FlowTextKey.TRY_END_TEXT]: "Then",
|
||||
[FlowTextKey.CATCH_TEXT]: "Catch",
|
||||
},
|
||||
formMeta: {
|
||||
render: () => <BranchNode description={type} />,
|
||||
},
|
||||
};
|
||||
},
|
||||
renderDefaultNode: NodeRender,
|
||||
},
|
||||
|
||||
plugins: () => [
|
||||
createMinimapPlugin({
|
||||
disableLayer: true,
|
||||
enableDisplayAllNodes: true,
|
||||
canvasStyle: {
|
||||
canvasWidth: 160,
|
||||
canvasHeight: 160,
|
||||
},
|
||||
}),
|
||||
],
|
||||
nodeRegistries: getAllNodeRegistries(),
|
||||
|
||||
onAllLayersRendered: (ctx) => {
|
||||
// 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分
|
||||
setTimeout(() => {
|
||||
ctx.playground.config.scroll({ scrollY: -80 });
|
||||
}, 1);
|
||||
},
|
||||
}),
|
||||
[themeToken, initialData, readonly]
|
||||
);
|
||||
getNodeDefaultRegistry(type) {
|
||||
return {
|
||||
type,
|
||||
meta: {
|
||||
defaultExpanded: true,
|
||||
},
|
||||
formMeta: {
|
||||
render: () => <BranchNode description={type} />,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
get container() {
|
||||
return flowgramEditorRef.current!.container;
|
||||
},
|
||||
get document() {
|
||||
return flowgramEditorRef.current!.document;
|
||||
},
|
||||
get playground() {
|
||||
return flowgramEditorRef.current!.playground;
|
||||
},
|
||||
get operation() {
|
||||
return flowgramEditorRef.current!.operation;
|
||||
},
|
||||
get clipboard() {
|
||||
return flowgramEditorRef.current!.clipboard;
|
||||
},
|
||||
get selection() {
|
||||
return flowgramEditorRef.current!.selection;
|
||||
},
|
||||
get history() {
|
||||
return flowgramEditorRef.current!.history;
|
||||
},
|
||||
plugins: () => [
|
||||
createMinimapPlugin({
|
||||
disableLayer: true,
|
||||
enableDisplayAllNodes: true,
|
||||
canvasStyle: {
|
||||
canvasWidth: 160,
|
||||
canvasHeight: 160,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
get(identifier) {
|
||||
return flowgramEditorRef.current!.get(identifier);
|
||||
},
|
||||
getAll(identifier) {
|
||||
return flowgramEditorRef.current!.getAll(identifier);
|
||||
},
|
||||
validateNode(node) {
|
||||
if (typeof node === "string") {
|
||||
node = flowgramEditorRef.current!.document.getNode(node)!;
|
||||
onInit: (ctx) => {
|
||||
const maybeMobile = ["android", "ios", "iphone", "ipad", "micromessenger"].some((s) => navigator.userAgent.includes(s));
|
||||
if (maybeMobile) {
|
||||
ctx.playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id);
|
||||
}
|
||||
},
|
||||
|
||||
onAllLayersRendered: (ctx) => {
|
||||
rendered.current = true;
|
||||
|
||||
// 画布初始化后向下滚动一点,露出可能被 Alert 遮挡的部分
|
||||
setTimeout(() => {
|
||||
ctx.playground.config.scroll({ scrollY: -80 });
|
||||
}, 1);
|
||||
},
|
||||
}),
|
||||
[themeToken, initialData, readonly, onDocumentChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const d = flowgramEditorRef.current!.document.originTree.onTreeChange(() => {
|
||||
if (rendered.current) {
|
||||
onDocumentChange?.(flowgramEditorRef.current!);
|
||||
}
|
||||
});
|
||||
|
||||
const form = getNodeForm(node);
|
||||
return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true);
|
||||
},
|
||||
validateAllNodes() {
|
||||
const nodes = flowgramEditorRef.current!.document.getAllNodes();
|
||||
const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null);
|
||||
return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid));
|
||||
},
|
||||
};
|
||||
});
|
||||
return () => d.dispose();
|
||||
}, [onDocumentChange]);
|
||||
|
||||
return (
|
||||
<FixedLayoutEditorProvider ref={flowgramEditorRef} {...flowgramEditorProps}>
|
||||
<DegisnerContextProvider value={{ onNodeClick: (node) => onNodeClick?.(flowgramEditorRef.current!, node) }}>
|
||||
<EditorRenderer className={className} style={style} />
|
||||
{children}
|
||||
</DegisnerContextProvider>
|
||||
</FixedLayoutEditorProvider>
|
||||
);
|
||||
});
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
get clipboard() {
|
||||
return flowgramEditorRef.current!.clipboard;
|
||||
},
|
||||
get container() {
|
||||
return flowgramEditorRef.current!.container;
|
||||
},
|
||||
get document() {
|
||||
return flowgramEditorRef.current!.document;
|
||||
},
|
||||
get history() {
|
||||
return flowgramEditorRef.current!.history;
|
||||
},
|
||||
get operation() {
|
||||
return flowgramEditorRef.current!.operation;
|
||||
},
|
||||
get playground() {
|
||||
return flowgramEditorRef.current!.playground;
|
||||
},
|
||||
get selection() {
|
||||
return flowgramEditorRef.current!.selection;
|
||||
},
|
||||
get tools() {
|
||||
return flowgramEditorRef.current!.tools;
|
||||
},
|
||||
|
||||
get(identifier) {
|
||||
return flowgramEditorRef.current!.get(identifier);
|
||||
},
|
||||
getAll(identifier) {
|
||||
return flowgramEditorRef.current!.getAll(identifier);
|
||||
},
|
||||
validateNode(node) {
|
||||
if (typeof node === "string") {
|
||||
node = flowgramEditorRef.current!.document.getNode(node)!;
|
||||
}
|
||||
|
||||
const form = getNodeForm(node);
|
||||
return form ? form.validate().then((res) => res && !form.state.invalid) : Promise.resolve(true);
|
||||
},
|
||||
validateAllNodes() {
|
||||
const nodes = flowgramEditorRef.current!.document.getAllNodes();
|
||||
const forms = nodes.map((node) => getNodeForm(node)).filter((form) => form != null);
|
||||
return Promise.allSettled(forms.map((form) => form.validate())).then((res) => forms.every((form, index) => res[index] && !form.state.invalid));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<FixedLayoutEditorProvider ref={flowgramEditorRef} {...flowgramEditorProps}>
|
||||
<DegisnerContextProvider
|
||||
value={{
|
||||
onDocumentChange: () => onDocumentChange?.(flowgramEditorRef.current!),
|
||||
onNodeChange: (node) => onNodeChange?.(flowgramEditorRef.current!, node),
|
||||
onNodeClick: (node) => onNodeClick?.(flowgramEditorRef.current!, node),
|
||||
}}
|
||||
>
|
||||
<EditorRenderer className={className} style={style} />
|
||||
{children}
|
||||
</DegisnerContextProvider>
|
||||
</FixedLayoutEditorProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Designer;
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor";
|
||||
|
||||
export type DesignerContextType = {
|
||||
onDocumentChange: () => void;
|
||||
onNodeChange: (node: FlowNodeEntity) => void;
|
||||
onNodeClick: (node: FlowNodeEntity) => void;
|
||||
};
|
||||
|
||||
export const DesignerContext = createContext<DesignerContextType>({
|
||||
onDocumentChange: () => {},
|
||||
onNodeChange: () => {},
|
||||
onNodeClick: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { startTransition, useMemo, useState } from "react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor";
|
||||
import { useControllableValue } from "ahooks";
|
||||
|
||||
@@ -14,6 +14,7 @@ import StartNodeConfigDrawer from "./forms/StartNodeConfigDrawer";
|
||||
import { NodeType } from "./nodes/typings";
|
||||
|
||||
export interface NodeDrawerProps {
|
||||
afterClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
node?: FlowNodeEntity;
|
||||
@@ -68,31 +69,41 @@ const NodeDrawer = ({ node, trigger, ...props }: NodeDrawerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useProps = () => {
|
||||
const [node, setNode] = useState<NodeDrawerProps["node"]>();
|
||||
const useDrawer = () => {
|
||||
type NodeDataType = NodeDrawerProps["node"];
|
||||
const [node, setNode] = useState<NodeDataType>();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
setOpen(open);
|
||||
|
||||
startTransition(() => {
|
||||
if (!open) {
|
||||
setNode(void 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
node,
|
||||
open,
|
||||
setNode,
|
||||
setOpen,
|
||||
onOpenChange,
|
||||
drawerProps: {
|
||||
afterClose: () => {
|
||||
startTransition(() => {
|
||||
if (!open) {
|
||||
setNode(void 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
node,
|
||||
open,
|
||||
onOpenChange,
|
||||
},
|
||||
|
||||
open: (node: NonNullable<NodeDataType>) => {
|
||||
setNode(node);
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _default = Object.assign(NodeDrawer, {
|
||||
useProps,
|
||||
useDrawer,
|
||||
});
|
||||
|
||||
export default _default;
|
||||
|
||||
@@ -14,16 +14,25 @@ const Node = (_: NodeProps) => {
|
||||
|
||||
const nodeRender = useNodeRender();
|
||||
|
||||
const designer = useDesignerContext();
|
||||
const { onDocumentChange: fireOnDocumentChange, onNodeChange: fireOnNodeChange, onNodeClick: fireOnNodeClick } = useDesignerContext();
|
||||
|
||||
useEffect(() => {
|
||||
const d = ctx.document.originTree.onTreeChange(() => refresh());
|
||||
|
||||
return () => d.dispose();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const d1 = nodeRender.form?.onFormValuesChange?.(() => refresh());
|
||||
const d2 = nodeRender.form?.onValidate?.(() => refresh());
|
||||
const d1 = nodeRender.form?.onFormValuesChange?.(() => {
|
||||
refresh();
|
||||
|
||||
fireOnNodeChange(nodeRender.node);
|
||||
fireOnDocumentChange();
|
||||
});
|
||||
const d2 = nodeRender.form?.onValidate?.(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
d1?.dispose();
|
||||
d2?.dispose();
|
||||
@@ -33,7 +42,7 @@ const Node = (_: NodeProps) => {
|
||||
const handleNodeClick = () => {
|
||||
const node = nodeRender.node;
|
||||
if (node.getNodeRegistry<NodeRegistry>().meta?.clickable) {
|
||||
designer.onNodeClick?.(node);
|
||||
fireOnNodeClick(node);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,18 +19,19 @@ const Toolbar = ({ className, style }: ToolbarProps) => {
|
||||
const ctx = useClientContext();
|
||||
const { playground } = ctx;
|
||||
|
||||
const tools = usePlaygroundTools({ minZoom: 0.1, maxZoom: 3, padding: 48 });
|
||||
const tools = usePlaygroundTools({ minZoom: 0.1, maxZoom: 3 });
|
||||
|
||||
const refresh = useRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
const d = playground.config.onReadonlyOrDisabledChange(() => refresh());
|
||||
|
||||
return () => d.dispose();
|
||||
}, [playground]);
|
||||
|
||||
const [isMinimapVisible, setIsMinimapVisible] = useState(window.screen.availWidth >= 1024);
|
||||
const [isMinimapVisible, setIsMinimapVisible] = useState(() => window.screen.availWidth >= 1024);
|
||||
|
||||
const [isMouseFriendly, setIsMouseFriendly] = useState(playground.editorState.is(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id));
|
||||
const [isMouseFriendly, setIsMouseFriendly] = useState(() => playground.editorState.is(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id));
|
||||
|
||||
const handleToggleLayout = useCallback(() => {
|
||||
if (tools.isVertical) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FlowNodeBaseType, type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor";
|
||||
|
||||
import { type WorkflowNode as _WorkflowNode, duplicateNode as _duplicateNode } from "@/domain/workflow";
|
||||
|
||||
import { type NodeJSON, NodeType } from "./nodes/typings";
|
||||
|
||||
/**
|
||||
* 克隆节点 JSON 对象。节点及其子节点 ID 均会重新分配。
|
||||
* @param {NodeJSON} node
|
||||
* @param {Object} options
|
||||
* @returns {NodeJSON}
|
||||
*/
|
||||
export const duplicateNodeJSON = (node: NodeJSON, options?: { withCopySuffix?: boolean }) => {
|
||||
return _duplicateNode(node as _WorkflowNode, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定节点到根节点为止的所有前序节点。不包括自身和根节点或开始节点。
|
||||
* @param {FlowNodeEntity} node
|
||||
* @returns {FlowNodeEntity[]}
|
||||
*/
|
||||
export const getAllPreviousNodes = (node: FlowNodeEntity): FlowNodeEntity[] => {
|
||||
if (node == null) return [];
|
||||
|
||||
// TODO: 不应该获取到旁路分支
|
||||
// // 先获取单一链路(即不包含分支)的全部节点
|
||||
// const chains: FlowNodeEntity[] = [];
|
||||
// let chain: FlowNodeEntity | undefined = node;
|
||||
// while (chain) {
|
||||
// if (chain.isStart || chain.flowNodeType === FlowNodeBaseType.ROOT) {
|
||||
// break;
|
||||
// }
|
||||
|
||||
// chains.push(chain);
|
||||
// chain = chain.pre ?? chain.parent;
|
||||
// }
|
||||
|
||||
// 再获取实际的全部节点
|
||||
const visited = new Set<string>();
|
||||
const result: FlowNodeEntity[] = [];
|
||||
let current: FlowNodeEntity | undefined = node;
|
||||
while (current) {
|
||||
if (current.isStart || current.flowNodeType === FlowNodeBaseType.ROOT) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.flowNodeType === NodeType.Condition) {
|
||||
/**
|
||||
* condition
|
||||
* blockIcon
|
||||
* inlineBlocks
|
||||
* branchBlock_1
|
||||
* blockOrderIcon
|
||||
* ...
|
||||
* branchBlock_2
|
||||
* blockOrderIcon
|
||||
* ...
|
||||
*/
|
||||
current.lastBlock?.blocks?.forEach((block) => {
|
||||
block.allChildren?.forEach((child) => {
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
result.push(child);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (current.flowNodeType === NodeType.TryCatch) {
|
||||
/**
|
||||
* tryCatch
|
||||
* blockIcon
|
||||
* mainInlineBlocks
|
||||
* tryBlock
|
||||
* trySlot
|
||||
* ...
|
||||
* catchInlineBlocks
|
||||
* catchBlock_1
|
||||
* blockOrderIcon
|
||||
* ...
|
||||
* end
|
||||
* catchBlock_2
|
||||
* blockOrderIcon
|
||||
* ...
|
||||
* end
|
||||
*/
|
||||
current.lastBlock?.blocks?.forEach((block) => {
|
||||
block.allChildren?.forEach((child) => {
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
result.push(child);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
result.push(current);
|
||||
}
|
||||
current = current.pre ?? current.parent;
|
||||
}
|
||||
|
||||
const prevNodes = result.filter((e) => {
|
||||
if (e.id === node.id) return false;
|
||||
if (e.isTypeOrExtendType(FlowNodeBaseType.BLOCK_ICON)) return false;
|
||||
if (e.isTypeOrExtendType(FlowNodeBaseType.BLOCK_ORDER_ICON)) return false;
|
||||
if (e.isTypeOrExtendType(FlowNodeBaseType.INLINE_BLOCKS)) return false;
|
||||
if (e.isTypeOrExtendType("trySlot")) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
// console.log(node.document.toString());
|
||||
// console.log(node.document.root);
|
||||
// console.log(prevNodes);
|
||||
return prevNodes;
|
||||
};
|
||||
@@ -31,12 +31,12 @@ const Adder = ({ from, hoverActivated }: AdderProps) => {
|
||||
})
|
||||
.reduce(
|
||||
(acc, registry) => {
|
||||
let group = acc.find((item) => item!.key === registry.kindType);
|
||||
let group = acc.find((item) => item!.key === registry.kind);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: registry.kindType,
|
||||
key: registry.kind,
|
||||
type: "group",
|
||||
label: registry.kindType ? t(`workflow_node.kind.${registry.kindType}`) : null,
|
||||
label: registry.kind ? t(`workflow_node.kind.${registry.kind}`) : null,
|
||||
children: [],
|
||||
};
|
||||
acc.push(group);
|
||||
|
||||
@@ -18,11 +18,15 @@ const BranchAdder = ({ node }: BranchAdderProps) => {
|
||||
let block: FlowNodeEntity;
|
||||
switch (node.flowNodeType) {
|
||||
case NodeType.Condition:
|
||||
block = operation.addBlock(node, BranchBlockNodeRegistry.onAdd!(ctx, node));
|
||||
{
|
||||
block = operation.addBlock(node, BranchBlockNodeRegistry.onAdd!(ctx, node));
|
||||
}
|
||||
break;
|
||||
|
||||
case NodeType.TryCatch:
|
||||
block = operation.addBlock(node, CatchBlockNodeRegistry.onAdd!(ctx, node));
|
||||
{
|
||||
block = operation.addBlock(node, CatchBlockNodeRegistry.onAdd!(ctx, node));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -38,7 +42,8 @@ const BranchAdder = ({ node }: BranchAdderProps) => {
|
||||
}, 1);
|
||||
};
|
||||
|
||||
return playground.config.readonlyOrDisabled ? null : (
|
||||
// TryCatch 暂不支持添加分支
|
||||
return playground.config.readonlyOrDisabled || node.flowNodeType === NodeType.TryCatch ? null : (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => node.firstChild?.renderData?.toggleMouseEnter()}
|
||||
|
||||
@@ -17,14 +17,13 @@ export const getAllElements = () => {
|
||||
|
||||
[FlowRendererKey.COLLAPSE]: Collapse,
|
||||
[FlowRendererKey.TRY_CATCH_COLLAPSE]: TryCatchCollapse,
|
||||
[FlowRendererKey.SLOT_COLLPASE]: Null,
|
||||
[FlowRendererKey.SLOT_COLLAPSE]: Null,
|
||||
|
||||
[FlowRendererKey.DRAG_NODE]: DragNode,
|
||||
[FlowRendererKey.DRAG_HIGHLIGHT_ADDER]: DragHighlightAdder,
|
||||
[FlowRendererKey.DRAG_BRANCH_HIGHLIGHT_ADDER]: DragHighlightAdder,
|
||||
[FlowRendererKey.DRAGGABLE_ADDER]: DraggingAdder,
|
||||
|
||||
[FlowRendererKey.CONTEXT_MENU_POPOVER]: Null,
|
||||
[FlowRendererKey.SELECTOR_BOX_POPOVER]: Null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import BizApplyNodeConfigForm from "./BizApplyNodeConfigForm";
|
||||
import { NodeType } from "../nodes/typings";
|
||||
|
||||
export interface BizApplyNodeConfigDrawerProps {
|
||||
afterClose?: () => void;
|
||||
loading?: boolean;
|
||||
node: FlowNodeEntity;
|
||||
open?: boolean;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getI18n, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor";
|
||||
import { IconChevronRight, IconCircleMinus, IconPlus } from "@tabler/icons-react";
|
||||
import { useControllableValue } from "ahooks";
|
||||
import { useAsyncEffect, useControllableValue } from "ahooks";
|
||||
import { type AnchorProps, AutoComplete, Button, Divider, Flex, Form, type FormInstance, Input, InputNumber, Select, Switch, Typography } from "antd";
|
||||
import { createSchemaFieldRule } from "antd-zod";
|
||||
import { z } from "zod";
|
||||
@@ -15,7 +15,7 @@ import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSele
|
||||
import CAProviderSelect from "@/components/provider/CAProviderSelect";
|
||||
import Show from "@/components/Show";
|
||||
import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider";
|
||||
import { type WorkflowNodeConfigForApply, defaultNodeConfigForApply } from "@/domain/workflow";
|
||||
import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow";
|
||||
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
|
||||
import { useAccessesStore } from "@/stores/access";
|
||||
import { useContactEmailsStore } from "@/stores/contact";
|
||||
@@ -46,7 +46,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
|
||||
const { accesses } = useAccessesStore(useZustandShallowSelector("accesses"));
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForApply | undefined;
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizApply | undefined;
|
||||
}, [node]);
|
||||
|
||||
const formSchema = getSchema({ i18n });
|
||||
@@ -245,6 +245,16 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item name="keyAlgorithm" label={t("workflow_node.apply.form.key_algorithm.label")} rules={[formRule]}>
|
||||
<Select
|
||||
options={["RSA2048", "RSA3072", "RSA4096", "RSA8192", "EC256", "EC384"].map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))}
|
||||
placeholder={t("workflow_node.apply.form.key_algorithm.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="relative" label={t("workflow_node.apply.form.ca_provider.label")}>
|
||||
<div className="absolute -top-[6px] right-0 -translate-y-full">
|
||||
<Show when={!fieldCAProvider}>
|
||||
@@ -302,16 +312,6 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="keyAlgorithm" label={t("workflow_node.apply.form.key_algorithm.label")} rules={[formRule]}>
|
||||
<Select
|
||||
options={["RSA2048", "RSA3072", "RSA4096", "RSA8192", "EC256", "EC384"].map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))}
|
||||
placeholder={t("workflow_node.apply.form.key_algorithm.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="acmeProfile"
|
||||
label={t("workflow_node.apply.form.acme_profile.label")}
|
||||
@@ -451,9 +451,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
|
||||
const InternalEmailInput = memo(
|
||||
({ disabled, placeholder, ...props }: { disabled?: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void }) => {
|
||||
const { emails, fetchEmails, removeEmail } = useContactEmailsStore();
|
||||
useEffect(() => {
|
||||
fetchEmails(false);
|
||||
}, []);
|
||||
useAsyncEffect(() => fetchEmails(false), []);
|
||||
|
||||
const [value, setValue] = useControllableValue<string>(props, {
|
||||
valuePropName: "value",
|
||||
@@ -538,7 +536,7 @@ const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
|
||||
domains: "",
|
||||
provider: "",
|
||||
providerAccessId: "",
|
||||
...defaultNodeConfigForApply(),
|
||||
...defaultNodeConfigForBizApply(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -555,13 +553,13 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
|
||||
}, t("common.errmsg.domain_invalid")),
|
||||
contactEmail: z.email(t("common.errmsg.email_invalid")),
|
||||
challengeType: z.string().nullish(),
|
||||
provider: z.string().nonempty(t("workflow_node.apply.form.provider.placeholder")),
|
||||
providerAccessId: z.string().nonempty(t("workflow_node.apply.form.provider_access.placeholder")),
|
||||
provider: z.string(t("workflow_node.apply.form.provider.placeholder")).nonempty(t("workflow_node.apply.form.provider.placeholder")),
|
||||
providerAccessId: z.string(t("workflow_node.apply.form.provider_access.placeholder")).nonempty(t("workflow_node.apply.form.provider_access.placeholder")),
|
||||
providerConfig: z.any().nullish(),
|
||||
caProvider: z.string().nullish(),
|
||||
caProviderAccessId: z.string().nullish(),
|
||||
caProviderConfig: z.any().nullish(),
|
||||
keyAlgorithm: z.string().nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")),
|
||||
keyAlgorithm: z.string(t("workflow_node.apply.form.key_algorithm.placeholder")).nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")),
|
||||
nameservers: z
|
||||
.string()
|
||||
.nullish()
|
||||
|
||||
@@ -7,6 +7,7 @@ import BizDeployNodeConfigForm from "./BizDeployNodeConfigForm";
|
||||
import { NodeType } from "../nodes/typings";
|
||||
|
||||
export interface BizDeployNodeConfigDrawerProps {
|
||||
afterClose?: () => void;
|
||||
loading?: boolean;
|
||||
node: FlowNodeEntity;
|
||||
open?: boolean;
|
||||
|
||||
@@ -12,10 +12,10 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi
|
||||
import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect";
|
||||
import Show from "@/components/Show";
|
||||
import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider";
|
||||
import { type WorkflowNodeConfigForDeploy, WorkflowNodeType, defaultNodeConfigForDeploy } from "@/domain/workflow";
|
||||
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
|
||||
import { useWorkflowStore } from "@/stores/workflow";
|
||||
import { type WorkflowNodeConfigForBizDeploy, defaultNodeConfigForBizDeploy } from "@/domain/workflow";
|
||||
import { useAntdForm } from "@/hooks";
|
||||
|
||||
import { getAllPreviousNodes } from "../_util";
|
||||
import { FormNestedFieldsContextProvider, NodeFormContextProvider } from "./_context";
|
||||
import BizDeployNodeConfigFormProvider1PanelConsole from "./BizDeployNodeConfigFormProvider1PanelConsole";
|
||||
import BizDeployNodeConfigFormProvider1PanelSite from "./BizDeployNodeConfigFormProvider1PanelSite";
|
||||
@@ -126,13 +126,21 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
|
||||
const { token: themeToken } = theme.useToken();
|
||||
|
||||
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForDeploy | undefined;
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizDeploy | undefined;
|
||||
}, [node]);
|
||||
|
||||
const formSchema = getSchema({ i18n });
|
||||
const formSchema = getSchema({ i18n }).superRefine((values, ctx) => {
|
||||
if (values.certificateOutputNodeId) {
|
||||
if (!certificateOutputNodeIdOptions.some((option) => option.value === values.certificateOutputNodeId)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("workflow_node.deploy.form.certificate_output_node_id.placeholder"),
|
||||
path: ["certificateOutputNodeId"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
const formRule = createSchemaFieldRule(formSchema);
|
||||
const { form: formInst, formProps } = useAntdForm({
|
||||
form: props.form,
|
||||
@@ -142,6 +150,17 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
|
||||
const fieldProvider = Form.useWatch<string>("provider", { form: formInst, preserve: true });
|
||||
|
||||
const certificateOutputNodeIdOptions = useMemo(() => {
|
||||
return getAllPreviousNodes(node)
|
||||
.filter((node) => node.flowNodeType === NodeType.BizApply || node.flowNodeType === NodeType.BizUpload)
|
||||
.map((node) => {
|
||||
return {
|
||||
label: getNodeForm(node)?.getValueIn("name"),
|
||||
value: node.id,
|
||||
};
|
||||
});
|
||||
}, [node]);
|
||||
|
||||
const [showProviderAccess, setShowProviderAccess] = useState(false);
|
||||
useEffect(() => {
|
||||
// 内置的部署提供商(如本地部署)无需显示授权信息字段
|
||||
@@ -153,25 +172,6 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
}
|
||||
}, [fieldProvider]);
|
||||
|
||||
// TODO: 适配 flowgram
|
||||
const certificateCandidates = useMemo(() => {
|
||||
const previousNodes = getWorkflowOuptutBeforeId(node.id, "certificate");
|
||||
return previousNodes
|
||||
.filter((node) => node.type === WorkflowNodeType.Apply || node.type === WorkflowNodeType.Upload)
|
||||
.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
options: (item.outputs ?? [])?.map((output) => {
|
||||
return {
|
||||
label: output.label,
|
||||
value: `${item.id}#${output.name}`,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.options.length > 0);
|
||||
}, [node.id]);
|
||||
|
||||
const NestedProviderConfigFields = useMemo(() => {
|
||||
/*
|
||||
注意:如果追加新的子组件,请保持以 ASCII 排序。
|
||||
@@ -369,7 +369,6 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
|
||||
const handleProviderPick = (value: string) => {
|
||||
formInst.setFieldValue("provider", value);
|
||||
console.log();
|
||||
};
|
||||
|
||||
const handleProviderSelect = (value?: string | undefined) => {
|
||||
@@ -380,7 +379,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
const oldValues = formInst.getFieldsValue();
|
||||
const newValues: Record<string, unknown> = {};
|
||||
for (const key in oldValues) {
|
||||
if (key === "provider" || key === "providerAccessId" || key === "certificate" || key === "skipOnLastSucceeded") {
|
||||
if (key === "certificateOutputNodeId" || key === "provider" || key === "providerAccessId" || key === "skipOnLastSucceeded") {
|
||||
newValues[key] = oldValues[key];
|
||||
} else {
|
||||
delete newValues[key];
|
||||
@@ -455,22 +454,24 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="certificate"
|
||||
label={t("workflow_node.deploy.form.certificate.label")}
|
||||
extra={t("workflow_node.deploy.form.certificate.help")}
|
||||
name="certificateOutputNodeId"
|
||||
label={t("workflow_node.deploy.form.certificate_output_node_id.label")}
|
||||
extra={t("workflow_node.deploy.form.certificate_output_node_id.help")}
|
||||
rules={[formRule]}
|
||||
>
|
||||
<Select
|
||||
labelRender={({ label, value }) => {
|
||||
if (value != null) {
|
||||
const group = certificateCandidates.find((group) => group.options.some((option) => option.value === value));
|
||||
return `${group?.label} - ${label}`;
|
||||
}
|
||||
|
||||
return <span style={{ color: themeToken.colorTextPlaceholder }}>{t("workflow_node.deploy.form.certificate.placeholder")}</span>;
|
||||
optionRender={({ label, value }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 overflow-hidden">
|
||||
<div className="flex-1 truncate">{label}</div>
|
||||
<div className="origin-right scale-90 font-mono text-xs" style={{ color: themeToken.colorTextSecondary }}>
|
||||
(NodeID: {value})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
options={certificateCandidates}
|
||||
placeholder={t("workflow_node.deploy.form.certificate.placeholder")}
|
||||
options={certificateOutputNodeIdOptions}
|
||||
placeholder={t("workflow_node.deploy.form.certificate_output_node_id.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -529,9 +530,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n
|
||||
|
||||
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
|
||||
return {
|
||||
certificate: "",
|
||||
provider: "",
|
||||
...defaultNodeConfigForDeploy(),
|
||||
...defaultNodeConfigForBizDeploy(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -540,8 +539,10 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
|
||||
|
||||
return z
|
||||
.object({
|
||||
certificate: z.string().nonempty(t("workflow_node.deploy.form.certificate.placeholder")),
|
||||
provider: z.string().nonempty(t("workflow_node.deploy.form.provider.placeholder")),
|
||||
certificateOutputNodeId: z
|
||||
.string(t("workflow_node.deploy.form.certificate_output_node_id.placeholder"))
|
||||
.nonempty(t("workflow_node.deploy.form.certificate_output_node_id.placeholder")),
|
||||
provider: z.string(t("workflow_node.deploy.form.provider.placeholder")).nonempty(t("workflow_node.deploy.form.provider.placeholder")),
|
||||
providerAccessId: z.string().nullish(),
|
||||
providerConfig: z.any().nullish(),
|
||||
skipOnLastSucceeded: z.boolean().nullish(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import BizMonitorNodeConfigForm from "./BizMonitorNodeConfigForm";
|
||||
import { NodeType } from "../nodes/typings";
|
||||
|
||||
export interface BizMonitorNodeConfigDrawerProps {
|
||||
afterClose?: () => void;
|
||||
loading?: boolean;
|
||||
node: FlowNodeEntity;
|
||||
open?: boolean;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSchemaFieldRule } from "antd-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import Tips from "@/components/Tips";
|
||||
import { type WorkflowNodeConfigForMonitor, defaultNodeConfigForMonitor } from "@/domain/workflow";
|
||||
import { type WorkflowNodeConfigForBizMonitor, defaultNodeConfigForBizMonitor } from "@/domain/workflow";
|
||||
import { useAntdForm } from "@/hooks";
|
||||
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
|
||||
|
||||
@@ -26,7 +26,7 @@ const BizMonitorNodeConfigForm = ({ node, ...props }: BizMonitorNodeConfigFormPr
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForMonitor | undefined;
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizMonitor | undefined;
|
||||
}, [node]);
|
||||
|
||||
const formSchema = getSchema({ i18n });
|
||||
@@ -83,7 +83,7 @@ const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n
|
||||
};
|
||||
|
||||
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
|
||||
return defaultNodeConfigForMonitor();
|
||||
return defaultNodeConfigForBizMonitor();
|
||||
};
|
||||
|
||||
const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import BizNotifyNodeConfigForm from "./BizNotifyNodeConfigForm";
|
||||
import { NodeType } from "../nodes/typings";
|
||||
|
||||
export interface BizNotifyNodeConfigDrawerProps {
|
||||
afterClose?: () => void;
|
||||
loading?: boolean;
|
||||
node: FlowNodeEntity;
|
||||
open?: boolean;
|
||||
|
||||
@@ -10,7 +10,7 @@ import AccessEditDrawer from "@/components/access/AccessEditDrawer";
|
||||
import AccessSelect from "@/components/access/AccessSelect";
|
||||
import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect";
|
||||
import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider";
|
||||
import { type WorkflowNodeConfigForNotify, defaultNodeConfigForNotify } from "@/domain/workflow";
|
||||
import { type WorkflowNodeConfigForBizNotify, defaultNodeConfigForBizNotify } from "@/domain/workflow";
|
||||
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
|
||||
import { useAccessesStore } from "@/stores/access";
|
||||
|
||||
@@ -38,7 +38,7 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp
|
||||
const { accesses } = useAccessesStore(useZustandShallowSelector("accesses"));
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForNotify | undefined;
|
||||
return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizNotify | undefined;
|
||||
}, [node]);
|
||||
|
||||
const formSchema = getSchema({ i18n });
|
||||
@@ -117,6 +117,14 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp
|
||||
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div id="channel" data-anchor="channel">
|
||||
<Divider size="small">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("workflow_node.notify.form_anchor.channel.title")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item name="provider" label={t("workflow_node.notify.form.provider.label")} hidden={!showProvider} rules={[formRule]}>
|
||||
<NotificationProviderSelect
|
||||
@@ -202,7 +210,7 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp
|
||||
const getAnchorItems = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }): Required<AnchorProps>["items"] => {
|
||||
const { t } = i18n;
|
||||
|
||||
return ["parameters", "strategy"].map((key) => ({
|
||||
return ["parameters", "channel", "strategy"].map((key) => ({
|
||||
key: key,
|
||||
title: t(`workflow_node.notify.form_anchor.${key}.tab`),
|
||||
href: "#" + key,
|
||||
@@ -213,9 +221,7 @@ const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
|
||||
return {
|
||||
subject: "",
|
||||
message: "",
|
||||
provider: "",
|
||||
providerAccessId: "",
|
||||
...defaultNodeConfigForNotify(),
|
||||
...defaultNodeConfigForBizNotify(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -231,8 +237,8 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
|
||||
.string()
|
||||
.min(1, t("workflow_node.notify.form.message.placeholder"))
|
||||
.max(20480, t("common.errmsg.string_max", { max: 20480 })),
|
||||
provider: z.string().nonempty(t("workflow_node.notify.form.provider.placeholder")),
|
||||
providerAccessId: z.string().nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
|
||||
provider: z.string(t("workflow_node.notify.form.provider.placeholder")).nonempty(t("workflow_node.notify.form.provider.placeholder")),
|
||||
providerAccessId: z.string(t("workflow_node.notify.form.provider_access.placeholder")).nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
|
||||
providerConfig: z.any().nullish(),
|
||||
skipOnAllPrevSkipped: z.boolean().nullish(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import BizUploadNodeConfigForm from "./BizUploadNodeConfigForm";
|
||||
import { NodeType } from "../nodes/typings";
|
||||
|
||||
export interface BizUploadNodeConfigDrawerProps {
|
||||
afterClose?: () => void;
|
||||
loading?: boolean;
|
||||
node: FlowNodeEntity;
|
||||
open?: boolean;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user