Compare commits

...

22 Commits

Author SHA1 Message Date
yoan
f799740d70 fix conflict 2024-11-18 20:22:21 +08:00
yoan
56886dcfe9 Merge branch 'LeoChen98-fix-reapply-when-domain-list-changed' 2024-11-18 20:03:16 +08:00
yoan
81e1e4a7ff validity duration 2024-11-18 20:03:11 +08:00
yoan
9b5256716f Merge branch 'fix-reapply-when-domain-list-changed' of github.com:LeoChen98/certimate into LeoChen98-fix-reapply-when-domain-list-changed 2024-11-18 19:58:36 +08:00
usual2970
446bf80f1d
Merge pull request #346 from jarod/main
feat: add deployer BytePlus CDN
2024-11-18 19:43:58 +08:00
yoan
775b12aec1 Add workflow execution process 2024-11-18 19:40:24 +08:00
Jarod Liu
6a80455c6c fix: byteplus access provider 2024-11-18 10:51:51 +08:00
Jarod Liu
d1df088662 fix: 补充Provider Access 的 UI 实现 2024-11-16 09:52:28 +08:00
Jarod Liu
2b0f7aaf8a feat: add deployer BytePlus CDN 2024-11-16 09:18:58 +08:00
Leo Chen
3265dd76ab edit comments for the forward changes 2024-11-15 20:45:08 +08:00
Leo Chen
d1d7b44303 Invert the changed logic to match the function name 2024-11-15 20:37:36 +08:00
Leo Chen
56eced3813 Invert the boolean value to match the function name 2024-11-15 20:36:47 +08:00
yoan
bde2147dd3 fix conflict 2024-11-15 10:27:10 +08:00
yoan
c853f2976f v0.2.20 2024-11-15 08:07:37 +08:00
usual2970
b66931003f
Merge pull request #342 from belier-cn/volcengine-cdn
feat: add volcengine cdn deployer
2024-11-15 08:05:27 +08:00
Leo Chen
9a75d2ac8f add key algorithm check 2024-11-15 00:33:09 +08:00
belier
42c5aea3f7 docs: update README_EN.md 2024-11-14 14:28:39 +08:00
belier
e2fd9c4cee style: modify variable name 2024-11-14 14:28:35 +08:00
belier
f847b7ff62 improvement: improve certificate fingerprint comparison 2024-11-14 14:19:00 +08:00
belier
9eae8f5077 feat: add volcengine cdn deployer 2024-11-14 13:39:23 +08:00
Leo Chen
41bd321a4f fixed: not reapply when domain list changed
fixed #334
2024-11-13 18:52:29 +08:00
Leo Chen
952e9687d0 fix misspelling var name 2024-11-13 17:58:56 +08:00
49 changed files with 1871 additions and 33 deletions

View File

@ -79,7 +79,7 @@ make local.run
| 华为云 | √ | √ | 可签发在华为云注册的域名;可部署到华为云 CDN、ELB |
| 七牛云 | | √ | 可部署到七牛云 CDN |
| 多吉云 | | √ | 可部署到多吉云 CDN |
| 火山引擎 | √ | √ | 可签发在火山引擎注册的域名;可部署到火山引擎 Live |
| 火山引擎 | √ | √ | 可签发在火山引擎注册的域名;可部署到火山引擎 Live、CDN |
| AWS | √ | | 可签发在 AWS Route53 托管的域名 |
| CloudFlare | √ | | 可签发在 CloudFlare 注册的域名CloudFlare 服务自带 SSL 证书 |
| GoDaddy | √ | | 可签发在 GoDaddy 注册的域名 |

View File

@ -71,14 +71,14 @@ password1234567890
## List of Supported Providers
| Provider | Registration | Deployment | Remarks |
| :-----------: | :----------: | :--------: | ----------------------------------------------------------------------------------------------------------- |
| :-----------: | :----------: | :--------: |-------------------------------------------------------------------------------------------------------------|
| Alibaba Cloud | √ | √ | Supports domains registered on Alibaba Cloud; supports deployment to Alibaba Cloud OSS, CDN,SLB |
| Tencent Cloud | √ | √ | Supports domains registered on Tencent Cloud; supports deployment to Tencent Cloud COS, CDN, ECDN, CLB, TEO |
| Baidu Cloud | | √ | Supports deployment to Baidu Cloud CDN |
| Huawei Cloud | √ | √ | Supports domains registered on Huawei Cloud; supports deployment to Huawei Cloud CDN, ELB |
| Qiniu Cloud | | √ | Supports deployment to Qiniu Cloud CDN |
| Doge Cloud | | √ | Supports deployment to Doge Cloud CDN |
| Volcengine | √ | √ | Supports domains registered on Volcengine; supports deployment to Volcengine Live |
| Volcengine | √ | √ | Supports domains registered on Volcengine; supports deployment to Volcengine Live, CDN |
| AWS | √ | | Supports domains managed on AWS Route53 |
| CloudFlare | √ | | Supports domains registered on CloudFlare; CloudFlare services come with SSL certificates |
| GoDaddy | √ | | Supports domains registered on GoDaddy |

1
go.mod
View File

@ -46,6 +46,7 @@ require (
github.com/alibabacloud-go/tea-utils/v2 v2.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.46.0 // indirect
github.com/blinkbean/dingtalk v1.1.3 // indirect
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.35 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-lark/lark v1.14.1 // indirect

4
go.sum
View File

@ -225,6 +225,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/blinkbean/dingtalk v1.1.3 h1:MbidFZYom7DTFHD/YIs+eaI7kRy52kmWE/sy0xjo6E4=
github.com/blinkbean/dingtalk v1.1.3/go.mod h1:9BaLuGSBqY3vT5hstValh48DbsKO7vaHaJnG9pXwbto=
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.35 h1:bM18V4iw9ylRc2LahQaq3k3gjEVJdyQYvptLVZaCa54=
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.35/go.mod h1:7iCaE+dR9EycrJU0GQyMhptbInLbQhsKXiDKDjNi8Vs=
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
@ -751,6 +753,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@ -1265,6 +1268,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=

View File

@ -1,6 +1,7 @@
package applicant
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
@ -170,7 +171,38 @@ func Get(record *models.Record) (Applicant, error) {
DisableFollowCNAME: applyConfig.DisableFollowCNAME,
}
switch access.GetString("configType") {
return GetWithTypeOption(access.GetString("configType"), option)
}
func GetWithApplyNode(node *domain.WorkflowNode) (Applicant, error) {
// 获取授权配置
accessRepo := repository.NewAccessRepository()
access, err := accessRepo.GetById(context.Background(), node.GetConfigString("access"))
if err != nil {
return nil, fmt.Errorf("access record not found: %w", err)
}
timeout := node.GetConfigInt64("timeout")
if timeout == 0 {
timeout = defaultTimeout
}
applyConfig := &ApplyOption{
Email: node.GetConfigString("email"),
Domain: node.GetConfigString("domain"),
Access: access.Config,
KeyAlgorithm: node.GetConfigString("keyAlgorithm"),
Nameservers: node.GetConfigString("nameservers"),
Timeout: timeout,
DisableFollowCNAME: node.GetConfigBool("disableFollowCNAME"),
}
return GetWithTypeOption(access.ConfigType, applyConfig)
}
func GetWithTypeOption(t string, option *ApplyOption) (Applicant, error) {
switch t {
case configTypeAliyun:
return NewAliyun(option), nil
case configTypeTencent:

View File

@ -0,0 +1,116 @@
package deployer
import (
"context"
"encoding/json"
"fmt"
"strings"
bytepluscdn "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/byteplus-cdn"
"github.com/byteplus-sdk/byteplus-sdk-golang/service/cdn"
xerrors "github.com/pkg/errors"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
)
type ByteplusCDNDeployer struct {
option *DeployerOption
infos []string
sdkClient *cdn.CDN
sslUploader uploader.Uploader
}
func NewByteplusCDNDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.ByteplusAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, xerrors.Wrap(err, "failed to get access")
}
client := cdn.NewInstance()
client.Client.SetAccessKey(access.AccessKey)
client.Client.SetSecretKey(access.SecretKey)
uploader, err := bytepluscdn.New(&bytepluscdn.ByteplusCDNUploaderConfig{
AccessKey: access.AccessKey,
SecretKey: access.SecretKey,
})
if err != nil {
return nil, xerrors.Wrap(err, "failed to create ssl uploader")
}
return &ByteplusCDNDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *ByteplusCDNDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *ByteplusCDNDeployer) GetInfos() []string {
return d.infos
}
func (d *ByteplusCDNDeployer) Deploy(ctx context.Context) error {
apiCtx := context.Background()
// 上传证书
upres, err := d.sslUploader.Upload(apiCtx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", upres))
domains := make([]string, 0)
configDomain := d.option.DeployConfig.GetConfigAsString("domain")
if strings.HasPrefix(configDomain, "*.") {
// 获取证书可以部署的域名
// REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-describecertconfig-9ea17
describeCertConfigReq := &cdn.DescribeCertConfigRequest{
CertId: upres.CertId,
}
describeCertConfigResp, err := d.sdkClient.DescribeCertConfig(describeCertConfigReq)
if err != nil {
return xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertConfig'")
}
for i := range describeCertConfigResp.Result.CertNotConfig {
// 当前未启用 HTTPS 的加速域名列表。
domains = append(domains, describeCertConfigResp.Result.CertNotConfig[i].Domain)
}
for i := range describeCertConfigResp.Result.OtherCertConfig {
// 已启用了 HTTPS 的加速域名列表。这些加速域名关联的证书不是您指定的证书。
domains = append(domains, describeCertConfigResp.Result.OtherCertConfig[i].Domain)
}
for i := range describeCertConfigResp.Result.SpecifiedCertConfig {
// 已启用了 HTTPS 的加速域名列表。这些加速域名关联了您指定的证书。
d.infos = append(d.infos, fmt.Sprintf("%s域名已配置该证书", describeCertConfigResp.Result.SpecifiedCertConfig[i].Domain))
}
if len(domains) == 0 {
if len(describeCertConfigResp.Result.SpecifiedCertConfig) > 0 {
// 所有匹配的域名都配置了该证书,跳过部署
return nil
} else {
return xerrors.Errorf("未查询到匹配的域名: %s", configDomain)
}
}
} else {
domains = append(domains, configDomain)
}
// 部署证书
// REF: https://github.com/byteplus-sdk/byteplus-sdk-golang/blob/master/service/cdn/api_list.go#L306
for i := range domains {
batchDeployCertReq := &cdn.BatchDeployCertRequest{
CertId: upres.CertId,
Domain: domains[i],
}
batchDeployCertResp, err := d.sdkClient.BatchDeployCert(batchDeployCertReq)
if err != nil {
return xerrors.Wrap(err, "failed to execute sdk request 'cdn.BatchDeployCert'")
} else {
d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), batchDeployCertResp))
}
}
return nil
}

View File

@ -41,6 +41,8 @@ const (
targetWebhook = "webhook"
targetK8sSecret = "k8s-secret"
targetVolcengineLive = "volcengine-live"
targetVolcengineCDN = "volcengine-cdn"
targetByteplusCDN = "byteplus-cdn"
)
type DeployerOption struct {
@ -153,6 +155,10 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
return NewK8sSecretDeployer(option)
case targetVolcengineLive:
return NewVolcengineLiveDeployer(option)
case targetVolcengineCDN:
return NewVolcengineCDNDeployer(option)
case targetByteplusCDN:
return NewByteplusCDNDeployer(option)
}
return nil, errors.New("unsupported deploy target")
}

View File

@ -101,9 +101,9 @@ func (d *TencentCDNDeployer) Deploy(ctx context.Context) error {
}
temp := make([]string, 0)
for _, aliInstanceId := range tcInstanceIds {
if !slices.Contains(deployedDomains, aliInstanceId) {
temp = append(temp, aliInstanceId)
for _, tcInstanceId := range tcInstanceIds {
if !slices.Contains(deployedDomains, tcInstanceId) {
temp = append(temp, tcInstanceId)
}
}
tcInstanceIds = temp

View File

@ -0,0 +1,116 @@
package deployer
import (
"context"
"encoding/json"
"fmt"
"strings"
volcenginecdn "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/volcengine-cdn"
xerrors "github.com/pkg/errors"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/volcengine/volc-sdk-golang/service/cdn"
)
type VolcengineCDNDeployer struct {
option *DeployerOption
infos []string
sdkClient *cdn.CDN
sslUploader uploader.Uploader
}
func NewVolcengineCDNDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.VolcengineAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, xerrors.Wrap(err, "failed to get access")
}
client := cdn.NewInstance()
client.Client.SetAccessKey(access.AccessKeyID)
client.Client.SetSecretKey(access.SecretAccessKey)
uploader, err := volcenginecdn.New(&volcenginecdn.VolcengineCDNUploaderConfig{
AccessKeyId: access.AccessKeyID,
AccessKeySecret: access.SecretAccessKey,
})
if err != nil {
return nil, xerrors.Wrap(err, "failed to create ssl uploader")
}
return &VolcengineCDNDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *VolcengineCDNDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *VolcengineCDNDeployer) GetInfos() []string {
return d.infos
}
func (d *VolcengineCDNDeployer) Deploy(ctx context.Context) error {
apiCtx := context.Background()
// 上传证书
upres, err := d.sslUploader.Upload(apiCtx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", upres))
domains := make([]string, 0)
configDomain := d.option.DeployConfig.GetConfigAsString("domain")
if strings.HasPrefix(configDomain, "*.") {
// 获取证书可以部署的域名
// REF: https://www.volcengine.com/docs/6454/125711
describeCertConfigReq := &cdn.DescribeCertConfigRequest{
CertId: upres.CertId,
}
describeCertConfigResp, err := d.sdkClient.DescribeCertConfig(describeCertConfigReq)
if err != nil {
return xerrors.Wrap(err, "failed to execute sdk request 'cdn.DescribeCertConfig'")
}
for i := range describeCertConfigResp.Result.CertNotConfig {
// 当前未启用 HTTPS 的加速域名列表。
domains = append(domains, describeCertConfigResp.Result.CertNotConfig[i].Domain)
}
for i := range describeCertConfigResp.Result.OtherCertConfig {
// 已启用了 HTTPS 的加速域名列表。这些加速域名关联的证书不是您指定的证书。
domains = append(domains, describeCertConfigResp.Result.OtherCertConfig[i].Domain)
}
for i := range describeCertConfigResp.Result.SpecifiedCertConfig {
// 已启用了 HTTPS 的加速域名列表。这些加速域名关联了您指定的证书。
d.infos = append(d.infos, fmt.Sprintf("%s域名已配置该证书", describeCertConfigResp.Result.SpecifiedCertConfig[i].Domain))
}
if len(domains) == 0 {
if len(describeCertConfigResp.Result.SpecifiedCertConfig) > 0 {
// 所有匹配的域名都配置了该证书,跳过部署
return nil
} else {
return xerrors.Errorf("未查询到匹配的域名: %s", configDomain)
}
}
} else {
domains = append(domains, configDomain)
}
// 部署证书
// REF: https://www.volcengine.com/docs/6454/125712
for i := range domains {
batchDeployCertReq := &cdn.BatchDeployCertRequest{
CertId: upres.CertId,
Domain: domains[i],
}
batchDeployCertResp, err := d.sdkClient.BatchDeployCert(batchDeployCertReq)
if err != nil {
return xerrors.Wrap(err, "failed to execute sdk request 'cdn.BatchDeployCert'")
} else {
d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), batchDeployCertResp))
}
}
return nil
}

View File

@ -91,11 +91,11 @@ func (d *VolcengineLiveDeployer) Deploy(ctx context.Context) error {
Domain: domains[i],
HTTPS: cast.BoolPtr(true),
}
BindCertResp, err := d.sdkClient.BindCert(apiCtx, bindCertReq)
bindCertResp, err := d.sdkClient.BindCert(apiCtx, bindCertReq)
if err != nil {
return xerrors.Wrap(err, "failed to execute sdk request 'live.BindCert'")
} else {
d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), BindCertResp))
d.infos = append(d.infos, toStr(fmt.Sprintf("%s域名的证书已修改", domains[i]), bindCertResp))
}
}

View File

@ -1,10 +1,26 @@
package domain
import "time"
type Access struct {
Meta
Name string `json:"name"`
Config string `json:"config"`
ConfigType string `json:"configType"`
Deleted time.Time `json:"deleted"`
Usage string `json:"usage"`
}
type AliyunAccess struct {
AccessKeyId string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
}
type ByteplusAccess struct {
AccessKey string
SecretKey string
}
type TencentAccess struct {
SecretId string `json:"secretId"`
SecretKey string `json:"secretKey"`

View File

@ -0,0 +1,39 @@
package domain
import "time"
type Certificate struct {
Meta
SAN string `json:"san"`
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
IssuerCertificate string `json:"issuerCertificate"`
CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"`
Output string `json:"output"`
ExpireAt time.Time `json:"ExpireAt"`
}
type MetaData struct {
Version string `json:"version"`
SerialNumber string `json:"serialNumber"`
Validity CertificateValidity `json:"validity"`
SignatureAlgorithm string `json:"signatureAlgorithm"`
Issuer CertificateIssuer `json:"issuer"`
Subject CertificateSubject `json:"subject"`
}
type CertificateIssuer struct {
Country string `json:"country"`
Organization string `json:"organization"`
CommonName string `json:"commonName"`
}
type CertificateSubject struct {
CN string `json:"CN"`
}
type CertificateValidity struct {
NotBefore string `json:"notBefore"`
NotAfter string `json:"notAfter"`
}

View File

@ -0,0 +1,9 @@
package domain
import "time"
type Meta struct {
Id string `json:"id"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}

View File

@ -1,6 +1,16 @@
package domain
var ErrAuthFailed = NewXError(4999, "auth failed")
var (
ErrInvalidParams = NewXError(400, "invalid params")
ErrRecordNotFound = NewXError(404, "record not found")
)
func IsRecordNotFound(err error) bool {
if e, ok := err.(*XError); ok {
return e.GetCode() == ErrRecordNotFound.GetCode()
}
return false
}
type XError struct {
Code int `json:"code"`

View File

@ -0,0 +1,89 @@
package domain
import (
"fmt"
"strconv"
)
const (
WorkflowNodeTypeStart = "start"
WorkflowNodeTypeEnd = "end"
WorkflowNodeTypeApply = "apply"
WorkflowNodeTypeDeply = "deploy"
WorkflowNodeTypeNotify = "notify"
WorkflowNodeTypeBranch = "branch"
WorkflowNodeTypeCondition = "condition"
)
type Workflow struct {
Meta
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Content *WorkflowNode `json:"content"`
Draft *WorkflowNode `json:"draft"`
Enabled bool `json:"enabled"`
HasDraft bool `json:"hasDraft"`
}
type WorkflowNode struct {
Id string `json:"id"`
Name string `json:"name"`
Next *WorkflowNode `json:"next"`
Config map[string]any `json:"config"`
Input []WorkflowNodeIo `json:"input"`
Output []WorkflowNodeIo `json:"output"`
Validated bool `json:"validated"`
Type string `json:"type"`
Branches []WorkflowNode `json:"branches"`
}
func (n *WorkflowNode) GetConfigString(key string) string {
if v, ok := n.Config[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (n *WorkflowNode) GetConfigBool(key string) bool {
if v, ok := n.Config[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return false
}
func (n *WorkflowNode) GetConfigInt64(key string) int64 {
// 先转成字符串,再转成 int64
if v, ok := n.Config[key]; ok {
temp := fmt.Sprintf("%v", v)
if i, err := strconv.ParseInt(temp, 10, 64); err == nil {
return i
}
}
return 0
}
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 struct {
Id string `json:"id"`
Name string `json:"name"`
}
type WorkflowRunReq struct {
Id string `json:"id"`
}

View File

@ -0,0 +1,12 @@
package domain
const WorkflowOutputCertificate = "certificate"
type WorkflowOutput struct {
Meta
Workflow string `json:"workflow"`
NodeId string `json:"nodeId"`
Node *WorkflowNode `json:"node"`
Output []WorkflowNodeIo `json:"output"`
Succeed bool `json:"succeed"`
}

View File

@ -2,14 +2,22 @@ package domains
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"fmt"
"strings"
"time"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/exp/slices"
"github.com/usual2970/certimate/internal/applicant"
"github.com/usual2970/certimate/internal/deployer"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/app"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
)
type Phase string
@ -20,6 +28,8 @@ const (
deployPhase Phase = "deploy"
)
const validityDuration = time.Hour * 24 * 10
func deploy(ctx context.Context, record *models.Record) error {
defer func() {
if r := recover(); r != nil {
@ -45,7 +55,10 @@ func deploy(ctx context.Context, record *models.Record) error {
cert := currRecord.GetString("certificate")
expiredAt := currRecord.GetDateTime("expiredAt").Time()
if cert != "" && time.Until(expiredAt) > time.Hour*24*10 && currRecord.GetBool("deployed") {
// 检查证书是否包含设置的所有域名
changed := isCertChanged(cert, currRecord)
if cert != "" && time.Until(expiredAt) > validityDuration && currRecord.GetBool("deployed") && !changed {
app.GetApp().Logger().Info("证书在有效期内")
history.record(checkPhase, "证书在有效期内且已部署,跳过", &RecordInfo{
Info: []string{fmt.Sprintf("证书有效期至 %s", expiredAt.Format("2006-01-02"))},
@ -60,7 +73,7 @@ func deploy(ctx context.Context, record *models.Record) error {
// ############2.申请证书
history.record(applyPhase, "开始申请", nil)
if cert != "" && time.Until(expiredAt) > time.Hour*24 {
if cert != "" && time.Until(expiredAt) > validityDuration && !changed {
history.record(applyPhase, "证书在有效期内,跳过", &RecordInfo{
Info: []string{fmt.Sprintf("证书有效期至 %s", expiredAt.Format("2006-01-02"))},
})
@ -121,3 +134,80 @@ func deploy(ctx context.Context, record *models.Record) error {
return nil
}
func isCertChanged(certificate string, record *models.Record) bool {
// 如果证书为空直接返回true
if certificate == "" {
return true
}
// 解析证书
cert, err := x509.ParseCertificateFromPEM(certificate)
if err != nil {
app.GetApp().Logger().Error("解析证书失败", "err", err)
return true
}
// 遍历域名列表检查是否都在证书中找到第一个不存在证书中域名时提前返回true
for _, domain := range strings.Split(record.GetString("domain"), ";") {
if !slices.Contains(cert.DNSNames, domain) && !slices.Contains(cert.DNSNames, "*."+removeLastSubdomain(domain)) {
return true
}
}
// 解析applyConfig
applyConfig := &domain.ApplyConfig{}
record.UnmarshalJSONField("applyConfig", applyConfig)
// 检查证书加密算法是否变更
switch pubkey := cert.PublicKey.(type) {
case *rsa.PublicKey:
bitSize := pubkey.N.BitLen()
switch bitSize {
case 2048:
// RSA2048
if applyConfig.KeyAlgorithm != "" && applyConfig.KeyAlgorithm != "RSA2048" {
return true
}
case 3072:
// RSA3072
if applyConfig.KeyAlgorithm != "RSA3072" {
return true
}
case 4096:
// RSA4096
if applyConfig.KeyAlgorithm != "RSA4096" {
return true
}
case 8192:
// RSA8192
if applyConfig.KeyAlgorithm != "RSA8192" {
return true
}
}
case *ecdsa.PublicKey:
bitSize := pubkey.Curve.Params().BitSize
switch bitSize {
case 256:
// EC256
if applyConfig.KeyAlgorithm != "EC256" {
return true
}
case 384:
// EC384
if applyConfig.KeyAlgorithm != "EC384" {
return true
}
}
}
return false
}
func removeLastSubdomain(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) > 1 {
return strings.Join(parts[1:], ".")
}
return domain
}

View File

@ -0,0 +1,111 @@
package bytepluscdn
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/byteplus-sdk/byteplus-sdk-golang/service/cdn"
xerrors "github.com/pkg/errors"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
)
type ByteplusCDNUploaderConfig struct {
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}
type ByteplusCDNUploader struct {
config *ByteplusCDNUploaderConfig
sdkClient *cdn.CDN
}
var _ uploader.Uploader = (*ByteplusCDNUploader)(nil)
func New(config *ByteplusCDNUploaderConfig) (*ByteplusCDNUploader, error) {
if config == nil {
return nil, errors.New("config is nil")
}
instance := cdn.NewInstance()
client := instance.Client
client.SetAccessKey(config.AccessKey)
client.SetSecretKey(config.SecretKey)
return &ByteplusCDNUploader{
config: config,
sdkClient: instance,
}, nil
}
func (u *ByteplusCDNUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) {
// 解析证书内容
certX509, err := x509.ParseCertificateFromPEM(certPem)
if err != nil {
return nil, err
}
// 查询证书列表,避免重复上传
// REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-listcertinfo
pageNum := int64(1)
pageSize := int64(100)
certSource := "cert_center"
listCertInfoReq := &cdn.ListCertInfoRequest{
PageNum: &pageNum,
PageSize: &pageSize,
Source: &certSource,
}
searchTotal := 0
for {
listCertInfoResp, err := u.sdkClient.ListCertInfo(listCertInfoReq)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ListCertInfo'")
}
if listCertInfoResp.Result.CertInfo != nil {
for _, certDetail := range listCertInfoResp.Result.CertInfo {
hash := sha256.Sum256(certX509.Raw)
isSameCert := strings.EqualFold(hex.EncodeToString(hash[:]), certDetail.CertFingerprint.Sha256)
// 如果已存在相同证书,直接返回已有的证书信息
if isSameCert {
return &uploader.UploadResult{
CertId: certDetail.CertId,
CertName: certDetail.Desc,
}, nil
}
}
}
searchTotal += len(listCertInfoResp.Result.CertInfo)
if int(listCertInfoResp.Result.Total) > searchTotal {
pageNum++
} else {
break
}
}
var certId, certName string
certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli())
// 上传新证书
// REF: https://docs.byteplus.com/en/docs/byteplus-cdn/reference-addcertificate
addCertificateReq := &cdn.AddCertificateRequest{
Certificate: certPem,
PrivateKey: privkeyPem,
Source: &certSource,
Desc: &certName,
}
addCertificateResp, err := u.sdkClient.AddCertificate(addCertificateReq)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.AddCertificate'")
}
certId = addCertificateResp.Result.CertId
return &uploader.UploadResult{
CertId: certId,
CertName: certName,
}, nil
}

View File

@ -0,0 +1,112 @@
package volcenginecdn
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
xerrors "github.com/pkg/errors"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
"github.com/volcengine/volc-sdk-golang/service/cdn"
)
type VolcengineCDNUploaderConfig struct {
AccessKeyId string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
}
type VolcengineCDNUploader struct {
config *VolcengineCDNUploaderConfig
sdkClient *cdn.CDN
}
var _ uploader.Uploader = (*VolcengineCDNUploader)(nil)
func New(config *VolcengineCDNUploaderConfig) (*VolcengineCDNUploader, error) {
if config == nil {
return nil, errors.New("config is nil")
}
instance := cdn.NewInstance()
client := instance.Client
client.SetAccessKey(config.AccessKeyId)
client.SetSecretKey(config.AccessKeySecret)
return &VolcengineCDNUploader{
config: config,
sdkClient: instance,
}, nil
}
func (u *VolcengineCDNUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) {
// 解析证书内容
certX509, err := x509.ParseCertificateFromPEM(certPem)
if err != nil {
return nil, err
}
// 查询证书列表,避免重复上传
// REF: https://www.volcengine.com/docs/6454/125709
pageNum := int64(1)
pageSize := int64(100)
certSource := "volc_cert_center"
listCertInfoReq := &cdn.ListCertInfoRequest{
PageNum: &pageNum,
PageSize: &pageSize,
Source: certSource,
}
searchTotal := 0
for {
listCertInfoResp, err := u.sdkClient.ListCertInfo(listCertInfoReq)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.ListCertInfo'")
}
if listCertInfoResp.Result.CertInfo != nil {
for _, certDetail := range listCertInfoResp.Result.CertInfo {
hash := sha256.Sum256(certX509.Raw)
isSameCert := strings.EqualFold(hex.EncodeToString(hash[:]), certDetail.CertFingerprint.Sha256)
// 如果已存在相同证书,直接返回已有的证书信息
if isSameCert {
return &uploader.UploadResult{
CertId: certDetail.CertId,
CertName: certDetail.Desc,
}, nil
}
}
}
searchTotal += len(listCertInfoResp.Result.CertInfo)
if int(listCertInfoResp.Result.Total) > searchTotal {
pageNum++
} else {
break
}
}
// 生成新证书名(需符合火山引擎命名规则)
var certId, certName string
certName = fmt.Sprintf("certimate-%d", time.Now().UnixMilli())
// 上传新证书
// REF: https://www.volcengine.com/docs/6454/1245763
addCertificateReq := &cdn.AddCertificateRequest{
Certificate: certPem,
PrivateKey: privkeyPem,
Source: &certSource,
Desc: &certName,
}
addCertificateResp, err := u.sdkClient.AddCertificate(addCertificateReq)
if err != nil {
return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.AddCertificate'")
}
certId = addCertificateResp.Result.CertId
return &uploader.UploadResult{
CertId: certId,
CertName: certName,
}, nil
}

View File

@ -0,0 +1,17 @@
package repository
import (
"context"
"github.com/usual2970/certimate/internal/domain"
)
type AccessRepository struct{}
func NewAccessRepository() *AccessRepository {
return &AccessRepository{}
}
func (a *AccessRepository) GetById(ctx context.Context, id string) (*domain.Access, error) {
return nil, nil
}

View File

@ -0,0 +1,54 @@
package repository
import (
"context"
"database/sql"
"errors"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/app"
)
type WorkflowRepository struct{}
func NewWorkflowRepository() *WorkflowRepository {
return &WorkflowRepository{}
}
func (w *WorkflowRepository) Get(ctx context.Context, id string) (*domain.Workflow, error) {
record, err := app.GetApp().Dao().FindRecordById("workflow", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
content := &domain.WorkflowNode{}
if err := record.UnmarshalJSONField("content", content); err != nil {
return nil, err
}
draft := &domain.WorkflowNode{}
if err := record.UnmarshalJSONField("draft", draft); err != nil {
return nil, err
}
workflow := &domain.Workflow{
Meta: domain.Meta{
Id: record.GetId(),
Created: record.GetTime("created"),
Updated: record.GetTime("updated"),
},
Name: record.GetString("name"),
Description: record.GetString("description"),
Type: record.GetString("type"),
Enabled: record.GetBool("enabled"),
HasDraft: record.GetBool("hasDraft"),
Content: content,
Draft: draft,
}
return workflow, nil
}

View File

@ -0,0 +1,105 @@
package repository
import (
"context"
"database/sql"
"errors"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/app"
)
type WorkflowOutputRepository struct{}
func NewWorkflowOutputRepository() *WorkflowOutputRepository {
return &WorkflowOutputRepository{}
}
func (w *WorkflowOutputRepository) Get(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error) {
record, err := app.GetApp().Dao().FindFirstRecordByFilter("workflow_output", "nodeId={:nodeId}", dbx.Params{"nodeId": nodeId})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
node := &domain.WorkflowNode{}
if err := record.UnmarshalJSONField("node", node); err != nil {
return nil, errors.New("failed to unmarshal node")
}
output := make([]domain.WorkflowNodeIo, 0)
if err := record.UnmarshalJSONField("output", &output); err != nil {
return nil, errors.New("failed to unmarshal output")
}
rs := &domain.WorkflowOutput{
Meta: domain.Meta{
Id: record.GetId(),
Created: record.GetTime("created"),
Updated: record.GetTime("updated"),
},
Workflow: record.GetString("workflow"),
NodeId: record.GetString("nodeId"),
Node: node,
Output: output,
}
return rs, nil
}
// 保存节点输出
func (w *WorkflowOutputRepository) Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error {
var record *models.Record
var err error
if output.Id == "" {
collection, err := app.GetApp().Dao().FindCollectionByNameOrId("workflow_output")
if err != nil {
return err
}
record = models.NewRecord(collection)
} else {
record, err = app.GetApp().Dao().FindRecordById("workflow_output", output.Id)
if err != nil {
return err
}
}
record.Set("workflow", output.Workflow)
record.Set("nodeId", output.NodeId)
record.Set("node", output.Node)
record.Set("output", output.Output)
if err := app.GetApp().Dao().SaveRecord(record); err != nil {
return err
}
if cb != nil && certificate != nil {
if err := cb(record.GetId()); err != nil {
return err
}
certCollection, err := app.GetApp().Dao().FindCollectionByNameOrId("certificate")
if err != nil {
return err
}
certRecord := models.NewRecord(certCollection)
certRecord.Set("certificate", certificate.Certificate)
certRecord.Set("privateKey", certificate.PrivateKey)
certRecord.Set("issuerCertificate", certificate.IssuerCertificate)
certRecord.Set("san", certificate.SAN)
certRecord.Set("workflowOutput", certificate.Output)
certRecord.Set("expireAt", certificate.ExpireAt)
certRecord.Set("certUrl", certificate.CertUrl)
certRecord.Set("certStableUrl", certificate.CertStableUrl)
if err := app.GetApp().Dao().SaveRecord(certRecord); err != nil {
return err
}
}
return nil
}

View File

@ -30,7 +30,7 @@ func NewNotifyHandler(route *echo.Group, service NotifyService) {
func (handler *notifyHandler) test(c echo.Context) error {
req := &domain.NotifyTestPushReq{}
if err := c.Bind(req); err != nil {
return err
return resp.Err(c, err)
}
if err := handler.service.Test(c.Request().Context(), req); err != nil {

39
internal/rest/workflow.go Normal file
View File

@ -0,0 +1,39 @@
package rest
import (
"context"
"github.com/labstack/echo/v5"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/resp"
)
type WorkflowService interface {
Run(ctx context.Context, req *domain.WorkflowRunReq) error
}
type workflowHandler struct {
service WorkflowService
}
func NewWorkflowHandler(route *echo.Group, service WorkflowService) {
handler := &workflowHandler{
service: service,
}
group := route.Group("/workflow")
group.POST("/run", handler.run)
}
func (handler *workflowHandler) run(c echo.Context) error {
req := &domain.WorkflowRunReq{}
if err := c.Bind(req); err != nil {
return resp.Err(c, err)
}
if err := handler.service.Run(c.Request().Context(), req); err != nil {
return resp.Err(c, err)
}
return resp.Succ(c, nil)
}

View File

@ -0,0 +1,103 @@
package nodeprocessor
import (
"context"
"github.com/usual2970/certimate/internal/applicant"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
"github.com/usual2970/certimate/internal/repository"
"github.com/usual2970/certimate/internal/utils/xtime"
)
type applyNode struct {
node *domain.WorkflowNode
outputRepo WorkflowOutputRepository
*Logger
}
func NewApplyNode(node *domain.WorkflowNode) *applyNode {
return &applyNode{
node: node,
Logger: NewLogger(node),
outputRepo: repository.NewWorkflowOutputRepository(),
}
}
type WorkflowOutputRepository interface {
// 查询节点输出
Get(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error)
// 保存节点输出
Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error
}
// 申请节点根据申请类型执行不同的操作
func (a *applyNode) Run(ctx context.Context) error {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "开始执行")
// 查询是否申请过,已申请过则直接返回(先保持和 v0.2 一致)
output, err := a.outputRepo.Get(ctx, a.node.Id)
if err != nil && !domain.IsRecordNotFound(err) {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "查询申请记录失败", err.Error())
return err
}
if output != nil && output.Succeed {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "已申请过")
return nil
}
// 获取Applicant
apply, err := applicant.GetWithApplyNode(a.node)
if err != nil {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "获取申请对象失败", err.Error())
return err
}
// 申请
certificate, err := apply.Apply()
if err != nil {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "申请失败", err.Error())
return err
}
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "申请成功")
// 记录申请结果
output = &domain.WorkflowOutput{
Workflow: GetWorkflowId(ctx),
NodeId: a.node.Id,
Node: a.node,
Succeed: true,
}
cert, err := x509.ParseCertificateFromPEM(certificate.Certificate)
if err != nil {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "解析证书失败", err.Error())
return err
}
certificateRecord := &domain.Certificate{
SAN: cert.Subject.CommonName,
Certificate: certificate.Certificate,
PrivateKey: certificate.PrivateKey,
IssuerCertificate: certificate.IssuerCertificate,
CertUrl: certificate.CertUrl,
CertStableUrl: certificate.CertStableUrl,
ExpireAt: cert.NotAfter,
}
if err := a.outputRepo.Save(ctx, output, certificateRecord, func(id string) error {
if certificateRecord != nil {
certificateRecord.Id = id
}
return nil
}); err != nil {
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "保存申请记录失败", err.Error())
return err
}
a.AddOutput(ctx, xtime.BeijingTimeStr(), a.node.Name, "保存申请记录成功")
return nil
}

View File

@ -0,0 +1,29 @@
package nodeprocessor
import (
"context"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/xtime"
)
type conditionNode struct {
node *domain.WorkflowNode
*Logger
}
func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
return &conditionNode{
node: node,
Logger: NewLogger(node),
}
}
// 条件节点没有任何操作
func (c *conditionNode) Run(ctx context.Context) error {
c.AddOutput(ctx, xtime.BeijingTimeStr(),
c.node.Name,
"完成",
)
return nil
}

View File

@ -0,0 +1 @@
package nodeprocessor

View File

@ -0,0 +1 @@
package nodeprocessor

View File

@ -0,0 +1,69 @@
package nodeprocessor
import (
"context"
"errors"
"github.com/usual2970/certimate/internal/domain"
)
type RunLog struct {
NodeName string `json:"node_name"`
Err string `json:"err"`
Outputs []RunLogOutput `json:"outputs"`
}
type RunLogOutput struct {
Time string `json:"time"`
Title string `json:"title"`
Content string `json:"content"`
Error string `json:"error"`
}
type NodeProcessor interface {
Run(ctx context.Context) error
Log(ctx context.Context) *RunLog
AddOutput(ctx context.Context, time, title, content string, err ...string)
}
type Logger struct {
log *RunLog
}
func NewLogger(node *domain.WorkflowNode) *Logger {
return &Logger{
log: &RunLog{
NodeName: node.Name,
Outputs: make([]RunLogOutput, 0),
},
}
}
func (l *Logger) Log(ctx context.Context) *RunLog {
return l.log
}
func (l *Logger) AddOutput(ctx context.Context, time, title, content string, err ...string) {
output := RunLogOutput{
Time: time,
Title: title,
Content: content,
}
if len(err) > 0 {
output.Error = err[0]
l.log.Err = err[0]
}
l.log.Outputs = append(l.log.Outputs, output)
}
func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) {
switch node.Type {
case domain.WorkflowNodeTypeStart:
return NewStartNode(node), nil
case domain.WorkflowNodeTypeCondition:
return NewConditionNode(node), nil
case domain.WorkflowNodeTypeApply:
return NewApplyNode(node), nil
}
return nil, errors.New("not implemented")
}

View File

@ -0,0 +1,29 @@
package nodeprocessor
import (
"context"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/xtime"
)
type startNode struct {
node *domain.WorkflowNode
*Logger
}
func NewStartNode(node *domain.WorkflowNode) *startNode {
return &startNode{
node: node,
Logger: NewLogger(node),
}
}
// 开始节点没有任何操作
func (s *startNode) Run(ctx context.Context) error {
s.AddOutput(ctx, xtime.BeijingTimeStr(),
s.node.Name,
"完成",
)
return nil
}

View File

@ -0,0 +1,64 @@
package nodeprocessor
import (
"context"
"github.com/usual2970/certimate/internal/domain"
)
type workflowProcessor struct {
workflow *domain.Workflow
logs []RunLog
}
func NewWorkflowProcessor(workflow *domain.Workflow) *workflowProcessor {
return &workflowProcessor{
workflow: workflow,
}
}
func (w *workflowProcessor) Run(ctx context.Context) error {
ctx = WithWorkflowId(ctx, w.workflow.Id)
return w.runNode(ctx, w.workflow.Content)
}
func (w *workflowProcessor) runNode(ctx context.Context, node *domain.WorkflowNode) error {
current := node
for current != nil {
if current.Type == domain.WorkflowNodeTypeBranch {
for _, branch := range current.Branches {
if err := w.runNode(ctx, &branch); err != nil {
continue
}
}
}
if current.Type != domain.WorkflowNodeTypeBranch {
processor, err := GetProcessor(current)
if err != nil {
return err
}
err = processor.Run(ctx)
log := processor.Log(ctx)
if log != nil {
w.logs = append(w.logs, *log)
}
if err != nil {
return err
}
}
}
return nil
}
func WithWorkflowId(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, "workflow_id", id)
}
func GetWorkflowId(ctx context.Context) string {
return ctx.Value("workflow_id").(string)
}

View File

@ -0,0 +1,52 @@
package workflow
import (
"context"
"fmt"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/app"
nodeprocessor "github.com/usual2970/certimate/internal/workflow/node-processor"
)
type WorkflowRepository interface {
Get(ctx context.Context, id string) (*domain.Workflow, error)
}
type WorkflowService struct {
repo WorkflowRepository
}
func NewWorkflowService(repo WorkflowRepository) *WorkflowService {
return &WorkflowService{
repo: repo,
}
}
func (s *WorkflowService) Run(ctx context.Context, req *domain.WorkflowRunReq) error {
// 查询
if req.Id == "" {
return domain.ErrInvalidParams
}
workflow, err := s.repo.Get(ctx, req.Id)
if err != nil {
app.GetApp().Logger().Error("failed to get workflow", "id", req.Id, "err", err)
return err
}
// 执行
if !workflow.Enabled {
app.GetApp().Logger().Error("workflow is disabled", "id", req.Id)
return fmt.Errorf("workflow is disabled")
}
processor := nodeprocessor.NewWorkflowProcessor(workflow)
if err := processor.Run(ctx); err != nil {
return fmt.Errorf("failed to run workflow: %w", err)
}
// 保存执行日志
return nil
}

View File

@ -0,0 +1,107 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
// update
edit_configType := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "hwy7m03o",
"name": "configType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"aliyun",
"tencent",
"huaweicloud",
"qiniu",
"aws",
"cloudflare",
"namesilo",
"godaddy",
"pdns",
"httpreq",
"local",
"ssh",
"webhook",
"k8s",
"baiducloud",
"dogecloud",
"volcengine",
"byteplus"
]
}
}`), edit_configType); err != nil {
return err
}
collection.Schema.AddField(edit_configType)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
// update
edit_configType := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "hwy7m03o",
"name": "configType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"aliyun",
"tencent",
"huaweicloud",
"qiniu",
"aws",
"cloudflare",
"namesilo",
"godaddy",
"pdns",
"httpreq",
"local",
"ssh",
"webhook",
"k8s",
"baiducloud",
"dogecloud",
"volcengine"
]
}
}`), edit_configType); err != nil {
return err
}
collection.Schema.AddField(edit_configType)
return dao.SaveCollection(collection)
})
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<path d="M0 0 C0.33 12.87 0.66 25.74 1 39 C4.63 36.03 8.26 33.06 12 30 C12.99 29.67 13.98 29.34 15 29 C15 28.34 15 27.68 15 27 C16.6953125 25.4765625 16.6953125 25.4765625 19.125 23.625 C24.36818245 19.55364629 29.38002727 15.25969115 34.35205078 10.86181641 C38.78276777 7 38.78276777 7 41 7 C41 6.34 41 5.68 41 5 C45.35294118 1 45.35294118 1 49 1 C49.02312269 12.19573244 49.04091471 23.39145924 49.05181217 34.58721066 C49.05704116 39.78528115 49.0641391 44.98333536 49.07543945 50.18139648 C49.08626776 55.19342268 49.09227671 60.20543318 49.09487724 65.21747017 C49.09673159 67.13412282 49.1003524 69.05077457 49.10573006 70.96742058 C49.11293975 73.64228072 49.11399097 76.31707514 49.11352539 78.99194336 C49.11712067 79.7925116 49.12071594 80.59307983 49.12442017 81.41790771 C49.11405216 86.88594784 49.11405216 86.88594784 48 88 C46.23140807 88.09946453 44.45888549 88.13080217 42.6875 88.1328125 C41.61242187 88.13410156 40.53734375 88.13539063 39.4296875 88.13671875 C38.29789062 88.13285156 37.16609375 88.12898438 36 88.125 C34.86820312 88.12886719 33.73640625 88.13273437 32.5703125 88.13671875 C31.49523437 88.13542969 30.42015625 88.13414062 29.3125 88.1328125 C28.31863281 88.13168457 27.32476562 88.13055664 26.30078125 88.12939453 C24 88 24 88 23 87 C22.84174742 85.00217874 22.74880829 82.99911166 22.68359375 80.99609375 C22.64169922 79.78115234 22.59980469 78.56621094 22.55664062 77.31445312 C22.51732422 76.03505859 22.47800781 74.75566406 22.4375 73.4375 C22.39431641 72.15423828 22.35113281 70.87097656 22.30664062 69.54882812 C22.200152 66.36600147 22.09814038 63.18309326 22 60 C21.24074219 60.63808594 20.48148437 61.27617188 19.69921875 61.93359375 C18.70535156 62.75988281 17.71148437 63.58617187 16.6875 64.4375 C15.70136719 65.26121094 14.71523437 66.08492188 13.69921875 66.93359375 C11 69 11 69 8 70 C8 70.66 8 71.32 8 72 C6.51261765 73.35382149 4.95569449 74.63139886 3.375 75.875 C-1.2643158 79.58405196 -5.81455578 83.3707187 -10.3125 87.25 C-11.99985624 88.70517299 -13.68754171 90.15975891 -15.38134766 91.60742188 C-16.47861169 92.55143304 -17.5666265 93.50628271 -18.64599609 94.47070312 C-22.67291886 98 -22.67291886 98 -26 98 C-26 84.8 -26 71.6 -26 58 C-29.3 60.64 -32.6 63.28 -36 66 C-36.66 66 -37.32 66 -38 66 C-38 66.66 -38 67.32 -38 68 C-39.71332754 69.58533702 -41.49689047 71.09541215 -43.3125 72.5625 C-44.29863281 73.36816406 -45.28476563 74.17382812 -46.30078125 75.00390625 C-49 77 -49 77 -52 78 C-52 78.66 -52 79.32 -52 80 C-53.42720409 81.26928798 -54.91983776 82.46538593 -56.4375 83.625 C-60.85617551 87.04712316 -64.96051883 90.6632284 -68.9921875 94.53125 C-71 96 -71 96 -75 96 C-75.02312283 84.93465142 -75.04091478 73.86930855 -75.05181217 62.80394077 C-75.05704113 57.66640434 -75.06413901 52.52888437 -75.07543945 47.39135742 C-75.08626787 42.4376859 -75.09227674 37.48403026 -75.09487724 32.53034782 C-75.09673157 30.63602384 -75.10035234 28.74170076 -75.10573006 26.8473835 C-75.11293989 24.20365614 -75.11399097 21.55999529 -75.11352539 18.91625977 C-75.11712067 18.12504227 -75.12071594 17.33382477 -75.12442017 16.51863098 C-75.11405216 11.11405216 -75.11405216 11.11405216 -74 10 C-72.21928754 9.91273777 -70.4351686 9.89300959 -68.65234375 9.90234375 C-67.57275391 9.90556641 -66.49316406 9.90878906 -65.38085938 9.91210938 C-63.67639648 9.92467773 -63.67639648 9.92467773 -61.9375 9.9375 C-60.22723633 9.94426758 -60.22723633 9.94426758 -58.48242188 9.95117188 C-55.65489649 9.96300253 -52.8274736 9.9794859 -50 10 C-48.65893595 12.6821281 -48.78549581 14.88398032 -48.68359375 17.8828125 C-48.64169922 19.04941406 -48.59980469 20.21601562 -48.55664062 21.41796875 C-48.51732422 22.64128906 -48.47800781 23.86460938 -48.4375 25.125 C-48.39431641 26.35605469 -48.35113281 27.58710937 -48.30664062 28.85546875 C-48.20031296 31.90352853 -48.09828219 34.9516719 -48 38 C-46.515 37.505 -46.515 37.505 -45 37 C-45 36.34 -45 35.68 -45 35 C-43.28667246 33.41466298 -41.50310953 31.90458785 -39.6875 30.4375 C-38.70136719 29.63183594 -37.71523437 28.82617188 -36.69921875 27.99609375 C-34 26 -34 26 -31 25 C-31 24.34 -31 23.68 -31 23 C-29.3046875 21.4765625 -29.3046875 21.4765625 -26.875 19.625 C-21.63181755 15.55364629 -16.61997273 11.25969115 -11.64794922 6.86181641 C-7.21723223 3 -7.21723223 3 -5 3 C-5 2.34 -5 1.68 -5 1 C-3 0 -3 0 0 0 Z " fill="#0260F9" transform="translate(113,51)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,194 @@
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ClientResponseError } from "pocketbase";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { PbErrorData } from "@/domain/base";
import { accessProvidersMap, accessTypeFormSchema, type Access, type ByteplusConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfigContext } from "@/providers/config";
type AccessByteplusFormProps = {
op: "add" | "edit" | "copy";
data?: Access;
onAfterReq: () => void;
};
const AccessByteplusForm = ({ data, op, onAfterReq }: AccessByteplusFormProps) => {
const { addAccess, updateAccess } = useConfigContext();
const { t } = useTranslation();
const formSchema = z.object({
id: z.string().optional(),
name: z
.string()
.min(1, "access.authorization.form.name.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
configType: accessTypeFormSchema,
accessKey: z
.string()
.min(1, "access.authorization.form.access_key.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
secretKey: z
.string()
.min(1, "access.authorization.form.secret_key.placeholder")
.max(64, t("common.errmsg.string_max", { max: 64 })),
});
let config: ByteplusConfig = {
accessKey: "",
secretKey: "",
};
if (data) config = data.config as ByteplusConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name || "",
configType: "byteplus",
accessKey: config.accessKey,
secretKey: config.secretKey,
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: accessProvidersMap.get(data.configType)!.usage,
config: {
accessKey: data.accessKey,
secretKey: data.secretKey,
},
};
try {
req.id = op == "copy" ? "" : req.id;
const rs = await save(req);
onAfterReq();
req.id = rs.id;
req.created = rs.created;
req.updated = rs.updated;
if (data.id && op == "edit") {
updateAccess(req);
return;
}
addAccess(req);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
});
return;
}
};
return (
<>
<Form {...form}>
<form
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.name.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.name.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t("access.authorization.form.config.label")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t("access.authorization.form.config.label")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.access_key.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.access_key.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t("access.authorization.form.secret_key.label")}</FormLabel>
<FormControl>
<Input placeholder={t("access.authorization.form.secret_key.placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
<div className="flex justify-end">
<Button type="submit">{t("common.save")}</Button>
</div>
</form>
</Form>
</>
);
};
export default AccessByteplusForm;

View File

@ -22,6 +22,7 @@ import AccessSSHForm from "./AccessSSHForm";
import AccessWebhookForm from "./AccessWebhookForm";
import AccessKubernetesForm from "./AccessKubernetesForm";
import AccessVolcengineForm from "./AccessVolcengineForm";
import AccessByteplusForm from "./AccessByteplusForm";
import { Access } from "@/domain/access";
import { AccessTypeSelect } from "./AccessTypeSelect";
@ -235,6 +236,17 @@ const AccessEditDialog = ({ trigger, op, data, className, outConfigType }: Acces
/>
);
break;
case "byteplus":
childComponent = (
<AccessByteplusForm
data={data}
op={op}
onAfterReq={() => {
setOpen(false);
}}
/>
);
break;
}
return (

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
@ -28,6 +27,8 @@ import DeployToSSH from "./DeployToSSH";
import DeployToWebhook from "./DeployToWebhook";
import DeployToKubernetesSecret from "./DeployToKubernetesSecret";
import DeployToVolcengineLive from "./DeployToVolcengineLive";
import DeployToVolcengineCDN from "./DeployToVolcengineCDN";
import DeployToByteplusCDN from "./DeployToByteplusCDN";
import { deployTargetsMap, type DeployConfig } from "@/domain/domain";
import { accessProvidersMap } from "@/domain/access";
import { useConfigContext } from "@/providers/config";
@ -178,6 +179,12 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
case "volcengine-live":
childComponent = <DeployToVolcengineLive />;
break;
case "volcengine-cdn":
childComponent = <DeployToVolcengineCDN />;
break;
case "byteplus-cdn":
childComponent = <DeployToByteplusCDN />;
break;
}
return (

View File

@ -0,0 +1,68 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeployEditContext } from "./DeployEdit";
type DeployToByteplusCDNConfigParams = {
domain?: string;
};
const DeployToByteplusCDN = () => {
const { t } = useTranslation();
const { config, setConfig, errors, setErrors } = useDeployEditContext<DeployToByteplusCDNConfigParams>();
useEffect(() => {
if (!config.id) {
setConfig({
...config,
config: {},
});
}
}, []);
useEffect(() => {
setErrors({});
}, []);
const formSchema = z.object({
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
}),
});
useEffect(() => {
const res = formSchema.safeParse(config.config);
setErrors({
...errors,
domain: res.error?.errors?.find((e) => e.path[0] === "domain")?.message,
});
}, [config]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.domain.label.wildsupported")}</Label>
<Input
placeholder={t("domain.deployment.form.domain.placeholder")}
className="w-full mt-1"
value={config?.config?.domain}
onChange={(e) => {
const nv = produce(config, (draft) => {
draft.config ??= {};
draft.config.domain = e.target.value?.trim();
});
setConfig(nv);
}}
/>
<div className="text-red-600 text-sm mt-1">{errors?.domain}</div>
</div>
</div>
);
};
export default DeployToByteplusCDN;

View File

@ -0,0 +1,68 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDeployEditContext } from "./DeployEdit";
type DeployToVolcengineCDNConfigParams = {
domain?: string;
};
const DeployToVolcengineCDN = () => {
const { t } = useTranslation();
const { config, setConfig, errors, setErrors } = useDeployEditContext<DeployToVolcengineCDNConfigParams>();
useEffect(() => {
if (!config.id) {
setConfig({
...config,
config: {},
});
}
}, []);
useEffect(() => {
setErrors({});
}, []);
const formSchema = z.object({
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
}),
});
useEffect(() => {
const res = formSchema.safeParse(config.config);
setErrors({
...errors,
domain: res.error?.errors?.find((e) => e.path[0] === "domain")?.message,
});
}, [config]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.domain.label.wildsupported")}</Label>
<Input
placeholder={t("domain.deployment.form.domain.placeholder")}
className="w-full mt-1"
value={config?.config?.domain}
onChange={(e) => {
const nv = produce(config, (draft) => {
draft.config ??= {};
draft.config.domain = e.target.value?.trim();
});
setConfig(nv);
}}
/>
<div className="text-red-600 text-sm mt-1">{errors?.domain}</div>
</div>
</div>
);
};
export default DeployToVolcengineCDN;

View File

@ -8,7 +8,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { Textarea } from "../ui/textarea";
type WorkflowNameEditDialogProps = {
trigger: React.ReactNode;
@ -30,6 +31,10 @@ const WorkflowNameBaseInfoDialog = ({ trigger }: WorkflowNameEditDialogProps) =>
resolver: zodResolver(formSchema),
});
useEffect(() => {
form.reset({ name: workflow.name, description: workflow.description });
}, [workflow]);
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@ -71,6 +76,7 @@ const WorkflowNameBaseInfoDialog = ({ trigger }: WorkflowNameEditDialogProps) =>
<Input
placeholder="请输入流程名称"
{...field}
value={field.value}
defaultValue={workflow.name}
onChange={(e) => {
form.setValue("name", e.target.value);
@ -90,9 +96,10 @@ const WorkflowNameBaseInfoDialog = ({ trigger }: WorkflowNameEditDialogProps) =>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
<Textarea
placeholder="请输入流程说明"
{...field}
value={field.value}
defaultValue={workflow.description}
onChange={(e) => {
form.setValue("description", e.target.value);

View File

@ -18,6 +18,7 @@ export const accessProviders = [
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛云:qiniu"],
["dogecloud", "common.provider.dogecloud", "/imgs/providers/dogecloud.svg", "deploy", "多吉云:doge cloud"],
["volcengine", "common.provider.volcengine", "/imgs/providers/volcengine.svg", "all", "火山引擎:volcengine"],
["byteplus", "common.provider.byteplus", "/imgs/providers/byteplus.svg", "all", "BytePlus"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"],
@ -53,6 +54,7 @@ export const accessTypeFormSchema = z.union(
z.literal("webhook"),
z.literal("k8s"),
z.literal("volcengine"),
z.literal("byteplus"),
],
{ message: "access.authorization.form.type.placeholder" }
);
@ -79,7 +81,8 @@ export type Access = {
| SSHConfig
| WebhookConfig
| KubernetesConfig
| VolcengineConfig;
| VolcengineConfig
| ByteplusConfig;
deleted?: string;
created?: string;
updated?: string;
@ -172,3 +175,8 @@ export type VolcengineConfig = {
accessKeyId: string;
secretAccessKey: string;
};
export type ByteplusConfig = {
accessKey: string;
secretKey: string;
};

View File

@ -92,6 +92,8 @@ export const deployTargetList: string[][] = [
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg"],
["k8s-secret", "common.provider.kubernetes.secret", "/imgs/providers/k8s.svg"],
["volcengine-live", "common.provider.volcengine.live", "/imgs/providers/volcengine.svg"],
["volcengine-cdn", "common.provider.volcengine.cdn", "/imgs/providers/volcengine.svg"],
["byteplus-cdn", "common.provider.byteplus.cdn", "/imgs/providers/byteplus.svg"],
];
export const deployTargetsMap: Map<DeployTarget["type"], DeployTarget> = new Map(

View File

@ -1 +1 @@
export const version = "Certimate v0.2.19";
export const version = "Certimate v0.2.20";

View File

@ -97,7 +97,6 @@ export type WorkflowNode = {
input?: WorkflowNodeIo[];
config?: WorkflowNodeConfig;
configured?: boolean;
output?: WorkflowNodeIo[];
next?: WorkflowNode | WorkflowBranchNode;
@ -156,6 +155,10 @@ export const newWorkflowNode = (type: WorkflowNodeType, options: NewWorkflowNode
};
}
if (type == WorkflowNodeType.Condition) {
rs.validated = true;
}
if (type === WorkflowNodeType.Branch) {
rs = {
...rs,
@ -351,6 +354,20 @@ export const allNodesValidated = (node: WorkflowNode | WorkflowBranchNode): bool
return true;
};
export const getExecuteMethod = (node: WorkflowNode): { type: string; crontab: string } => {
if (node.type === WorkflowNodeType.Start) {
return {
type: (node.config?.executionMethod as string) ?? "",
crontab: (node.config?.crontab as string) ?? "",
};
} else {
return {
type: "",
crontab: "",
};
}
};
export type WorkflowBranchNode = {
id: string;
name: string;

View File

@ -92,5 +92,8 @@
"common.provider.serverchan": "ServerChan",
"common.provider.bark": "Bark",
"common.provider.volcengine": "Volcengine",
"common.provider.volcengine.live": "Volcengine - Live"
"common.provider.volcengine.live": "Volcengine - Live",
"common.provider.volcengine.cdn": "Volcengine - CDN",
"common.provider.byteplus": "BytePlus",
"common.provider.byteplus.cdn": "BytePlus - CDN"
}

View File

@ -92,5 +92,8 @@
"common.provider.serverchan": "Server酱",
"common.provider.bark": "Bark",
"common.provider.volcengine": "火山引擎",
"common.provider.volcengine.live": "火山引擎 - 视频直播"
"common.provider.volcengine.live": "火山引擎 - 视频直播",
"common.provider.volcengine.cdn": "火山引擎 - CDN",
"common.provider.byteplus": "BytePlus",
"common.provider.byteplus.cdn": "BytePlus - CDN"
}

View File

@ -96,8 +96,8 @@ const WorkflowDetail = () => {
<WorkflowBaseInfoEditDialog
trigger={
<div className="flex flex-col space-y-1 cursor-pointer items-start">
<div className="">{workflow.name ? workflow.name : "未命名工作流"}</div>
<div className="text-sm text-muted-foreground">{workflow.description ? workflow.description : "添加流程说明"}</div>
<div className="truncate max-w-[200px]">{workflow.name ? workflow.name : "未命名工作流"}</div>
<div className="text-sm text-muted-foreground truncate max-w-[200px]">{workflow.description ? workflow.description : "添加流程说明"}</div>
</div>
}
/>

View File

@ -43,7 +43,7 @@ const Workflow = () => {
if (!name) {
name = "未命名工作流";
}
return <div className="flex items-center">{name}</div>;
return <div className="max-w-[150px] truncate">{name}</div>;
},
},
{
@ -54,23 +54,23 @@ const Workflow = () => {
if (!description) {
description = "-";
}
return description;
return <div className="max-w-[200px] truncate">{description}</div>;
},
},
{
accessorKey: "executionMethod",
accessorKey: "type",
header: "执行方式",
cell: ({ row }) => {
const method = row.getValue("executionMethod");
const method = row.getValue("type");
if (!method) {
return "-";
} else if (method === "manual") {
return "手动";
} else if (method === "auto") {
const crontab: string = row.getValue("crontab");
const crontab: string = row.original.crontab ?? "";
return (
<div className="flex flex-col">
<div className="flex flex-col space-y-1">
<div></div>
<div className="text-muted-foreground text-xs">{crontab}</div>
</div>
);

View File

@ -1,6 +1,7 @@
import {
addBranch,
addNode,
getExecuteMethod,
getWorkflowOutputBeforeId,
initWorkflow,
removeBranch,
@ -76,11 +77,15 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
});
},
switchEnable: async () => {
const root = get().workflow.draft as WorkflowNode;
const executeMethod = getExecuteMethod(root);
const resp = await save({
id: (get().workflow.id as string) ?? "",
content: get().workflow.draft as WorkflowNode,
content: root,
enabled: !get().workflow.enabled,
hasDraft: false,
type: executeMethod.type,
crontab: executeMethod.crontab,
});
set((state: WorkflowState) => {
return {
@ -90,15 +95,21 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
content: resp.content,
enabled: resp.enabled,
hasDraft: false,
type: resp.type,
crontab: resp.crontab,
},
};
});
},
save: async () => {
const root = get().workflow.draft as WorkflowNode;
const executeMethod = getExecuteMethod(root);
const resp = await save({
id: (get().workflow.id as string) ?? "",
content: get().workflow.draft as WorkflowNode,
content: root,
hasDraft: false,
type: executeMethod.type,
crontab: executeMethod.crontab,
});
set((state: WorkflowState) => {
return {
@ -107,6 +118,8 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
id: resp.id,
content: resp.content,
hasDraft: false,
type: resp.type,
crontab: resp.crontab,
},
};
});
@ -205,4 +218,3 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
return getWorkflowOutputBeforeId(get().workflow.draft as WorkflowNode, id, type);
},
}));