Merge pull request #926 from fudiwei/dev

This commit is contained in:
RHQYZ
2025-08-22 12:59:24 +08:00
committed by GitHub
271 changed files with 5490 additions and 18572 deletions
+9 -8
View File
@@ -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, ";")
+29 -31
View File
@@ -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
}
+19
View File
@@ -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)
}
+4 -4
View File
@@ -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),
+1 -2
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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"
+13 -6
View File
@@ -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"`
}
+6 -6
View File
@@ -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"
)
+4 -4
View File
@@ -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 := &notifierProviderOptions{
Provider: domain.NotificationProviderType(nodeCfg.Provider),
ProviderAccessConfig: make(map[string]any),
+2 -2
View File
@@ -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{
+2 -2
View File
@@ -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 := &registration.Resource{}
if err := record.UnmarshalJSONField("resource", resource); err != nil {
return nil, err
return nil, fmt.Errorf("field 'resource' is malformed")
}
acmeAccount := &domain.AcmeAccount{
+4 -5
View File
@@ -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
+13 -11
View File
@@ -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(),
+2 -2
View File
@@ -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{
+8 -59
View File
@@ -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 {
+43 -6
View File
@@ -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
+12 -9
View File
@@ -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)
}
+4 -3
View File
@@ -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)
}
+8 -6
View File
@@ -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)
}
+4 -2
View File
@@ -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))
}
}
-4
View File
@@ -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
}
+11
View File
@@ -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)
}
+22
View File
@@ -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)
}
+230 -213
View File
@@ -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(),
}
}
-142
View File
@@ -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
}
+3 -22
View File
@@ -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
}
+13
View File
@@ -0,0 +1,13 @@
package dispatcher
import (
"context"
)
type taskInfo struct {
WorkflowId string
RunId string
ctx context.Context
cancel context.CancelFunc
}
+58
View File
@@ -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,
}
}
+26
View File
@@ -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)
}
+296
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package engine
import (
"errors"
)
var errInterrupted = errors.New("workflow engine: interrupted, may be ended")
+157
View File
@@ -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()},
}
}
+22
View File
@@ -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()},
}
}
+9
View File
@@ -0,0 +1,9 @@
package engine
import (
"log/slog"
)
type withLogger interface {
SetLogger(logger *slog.Logger)
}
+26
View File
@@ -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
+283
View File
@@ -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"
)
+7 -4
View File
@@ -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"
)
-103
View File
@@ -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 &notifyNode{
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, ""
}
+65 -67
View File
@@ -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
}
+26
View File
@@ -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)
}
+1 -1
View File
@@ -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))
}
}
-581
View File
@@ -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
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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>
+354 -344
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -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"
+25 -7
View File
@@ -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",
}}
>
+6 -9
View File
@@ -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);
};
+32 -18
View File
@@ -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]}>
+2 -3
View File
@@ -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:
+166 -131
View File
@@ -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