diff --git a/go.mod b/go.mod
index fbe17a35a..4d7e2d8bc 100644
--- a/go.mod
+++ b/go.mod
@@ -256,6 +256,7 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
+ github.com/zenlayer/zenlayercloud-sdk-go v0.2.33 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
diff --git a/go.sum b/go.sum
index aa77b9de6..325ec1c7c 100644
--- a/go.sum
+++ b/go.sum
@@ -982,6 +982,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+github.com/zenlayer/zenlayercloud-sdk-go v0.2.33 h1:lfzoPBtKyCFktOk69IfvpMI3n3o6YHSV/NVp3qQQ1mU=
+github.com/zenlayer/zenlayercloud-sdk-go v0.2.33/go.mod h1:iOR3Pzb2t3Z0+UvXhdUqi7nhwZM5Ux2oH4ixKH+qe88=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
diff --git a/internal/certmgmt/deployers/sp_zenlayer_ga.go b/internal/certmgmt/deployers/sp_zenlayer_ga.go
new file mode 100644
index 000000000..95fb9aa66
--- /dev/null
+++ b/internal/certmgmt/deployers/sp_zenlayer_ga.go
@@ -0,0 +1,29 @@
+package deployers
+
+import (
+ "fmt"
+
+ "github.com/certimate-go/certimate/internal/domain"
+ "github.com/certimate-go/certimate/pkg/core/deployer"
+ zenlayerga "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-ga"
+ xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
+)
+
+func init() {
+ Registries.MustRegister(domain.DeploymentProviderTypeZenlayerGA, func(options *ProviderFactoryOptions) (deployer.Provider, error) {
+ credentials := domain.AccessConfigForZenlayer{}
+ if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
+ return nil, fmt.Errorf("failed to populate provider access config: %w", err)
+ }
+
+ provider, err := zenlayerga.NewDeployer(&zenlayerga.DeployerConfig{
+ AccessKeyId: credentials.AccessKeyId,
+ AccessKeyPassword: credentials.AccessKeyPassword,
+ ResourceGroupId: credentials.ResourceGroupId,
+ ResourceType: xmaps.GetString(options.ProviderExtendedConfig, "resourceType"),
+ AcceleratorId: xmaps.GetString(options.ProviderExtendedConfig, "acceleratorId"),
+ CertificateId: xmaps.GetString(options.ProviderExtendedConfig, "certificateId"),
+ })
+ return provider, err
+ })
+}
diff --git a/internal/domain/access.go b/internal/domain/access.go
index 73cf44aea..2ed71e329 100644
--- a/internal/domain/access.go
+++ b/internal/domain/access.go
@@ -621,6 +621,12 @@ type AccessConfigForXinnet struct {
ApiPassword string `json:"apiPassword"`
}
+type AccessConfigForZenlayer struct {
+ AccessKeyId string `json:"accessKeyId"`
+ AccessKeyPassword string `json:"accessKeyPassword"`
+ ResourceGroupId string `json:"resourceGroupId,omitempty"`
+}
+
type AccessConfigForZeroSSL struct {
AccessConfigForACMEExternalAccountBinding
}
diff --git a/internal/domain/provider.go b/internal/domain/provider.go
index 832313a4f..08cb4b92d 100644
--- a/internal/domain/provider.go
+++ b/internal/domain/provider.go
@@ -123,6 +123,7 @@ const (
AccessProviderTypeWeComBot = AccessProviderType("wecombot")
AccessProviderTypeWestcn = AccessProviderType("westcn")
AccessProviderTypeXinnet = AccessProviderType("xinnet")
+ AccessProviderTypeZenlayer = AccessProviderType("zenlayer")
AccessProviderTypeZeroSSL = AccessProviderType("zerossl")
)
@@ -391,6 +392,7 @@ const (
DeploymentProviderTypeWangsuCDNPro = DeploymentProviderType(AccessProviderTypeWangsu + "-cdnpro")
DeploymentProviderTypeWangsuCertificate = DeploymentProviderType(AccessProviderTypeWangsu + "-certificate")
DeploymentProviderTypeWebhook = DeploymentProviderType(AccessProviderTypeWebhook)
+ DeploymentProviderTypeZenlayerGA = DeploymentProviderType(AccessProviderTypeZenlayer + "-ga")
)
type NotificationProviderType string
diff --git a/pkg/core/certmgr/providers/zenlayer-ga/internal/client.go b/pkg/core/certmgr/providers/zenlayer-ga/internal/client.go
new file mode 100644
index 000000000..834071bde
--- /dev/null
+++ b/pkg/core/certmgr/providers/zenlayer-ga/internal/client.go
@@ -0,0 +1,46 @@
+package internal
+
+import (
+ "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
+ zga "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706"
+)
+
+// This is a partial copy of https://github.com/zenlayer/zenlayercloud-sdk-go/blob/main/zenlayercloud/zga20230706/client.go
+// to lightweight the vendor packages in the built binary.
+type ZgaClient struct {
+ *common.Client
+}
+
+func NewZgaClient(config *common.Config, secretKeyId, secretKeyPassword string) (client *ZgaClient, err error) {
+ client = &ZgaClient{}
+
+ err = client.InitWithCredential(common.NewCredential(secretKeyId, secretKeyPassword))
+ if err != nil {
+ return nil, err
+ }
+
+ err = client.WithConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return client, nil
+}
+
+func (c *ZgaClient) CreateCertificate(request *zga.CreateCertificateRequest) (response *zga.CreateCertificateResponse, err error) {
+ response = zga.NewCreateCertificateResponse()
+ err = c.ApiCall(request, response)
+ return
+}
+
+func (c *ZgaClient) DescribeCertificates(request *zga.DescribeCertificatesRequest) (response *zga.DescribeCertificatesResponse, err error) {
+ response = zga.NewDescribeCertificatesResponse()
+ err = c.ApiCall(request, response)
+ return
+}
+
+func (c *ZgaClient) ModifyCertificate(request *zga.ModifyCertificateRequest) (response *zga.ModifyCertificateResponse, err error) {
+ response = zga.NewModifyCertificateResponse()
+ err = c.ApiCall(request, response)
+ return
+}
diff --git a/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go b/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go
new file mode 100644
index 000000000..ca83713f4
--- /dev/null
+++ b/pkg/core/certmgr/providers/zenlayer-ga/zenlayer_ga.go
@@ -0,0 +1,202 @@
+package zenlayerga
+
+import (
+ "context"
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/x509"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "time"
+
+ zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
+ zga "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706"
+
+ "github.com/certimate-go/certimate/pkg/core/certmgr"
+ "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-ga/internal"
+ xcert "github.com/certimate-go/certimate/pkg/utils/cert"
+)
+
+type CertmgrConfig struct {
+ // Zenlayer AccessKeyId。
+ AccessKeyId string `json:"accessKeyId"`
+ // Zenlayer AccessKeyPassword。
+ AccessKeyPassword string `json:"accessKeyPassword"`
+ // Zenlayer 资源组 ID。
+ ResourceGroupId string `json:"resourceGroupId,omitempty"`
+}
+
+type Certmgr struct {
+ config *CertmgrConfig
+ logger *slog.Logger
+ sdkClient *internal.ZgaClient
+}
+
+var _ certmgr.Provider = (*Certmgr)(nil)
+
+func NewCertmgr(config *CertmgrConfig) (*Certmgr, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the certmgr provider is nil")
+ }
+
+ client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
+ if err != nil {
+ return nil, fmt.Errorf("could not create client: %w", err)
+ }
+
+ return &Certmgr{
+ config: config,
+ logger: slog.Default(),
+ sdkClient: client,
+ }, nil
+}
+
+func (c *Certmgr) SetLogger(logger *slog.Logger) {
+ if logger == nil {
+ c.logger = slog.New(slog.DiscardHandler)
+ } else {
+ c.logger = logger
+ }
+}
+
+func (c *Certmgr) Upload(ctx context.Context, certPEM, privkeyPEM string) (*certmgr.UploadResult, error) {
+ // 解析证书内容
+ certX509, err := xcert.ParseCertificateFromPEM(certPEM)
+ if err != nil {
+ return nil, err
+ }
+
+ // 查询证书列表,避免重复上传
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/describecertificates
+ describeCertificatesPageNum := 1
+ describeCertificatesPageSize := 100
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+
+ describeCertificatesReq := zga.NewDescribeCertificatesRequest()
+ describeCertificatesReq.ResourceGroupId = c.config.ResourceGroupId
+ describeCertificatesReq.PageNum = describeCertificatesPageNum
+ describeCertificatesReq.PageSize = describeCertificatesPageSize
+ describeCertificatesResp, err := c.sdkClient.DescribeCertificates(describeCertificatesReq)
+ c.logger.Debug("sdk request 'zga.DescribeCertificates'", slog.Any("request", describeCertificatesReq), slog.Any("response", describeCertificatesResp))
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute sdk request 'zga.DescribeCertificates': %w", err)
+ }
+
+ for _, certItem := range describeCertificatesResp.Response.DataSet {
+ // 对比证书通用名称
+ if !strings.EqualFold(certX509.Subject.CommonName, certItem.Common) {
+ continue
+ }
+
+ // 对比证书扩展名称
+ if !slices.Equal(certX509.DNSNames, certItem.Sans) {
+ continue
+ }
+
+ // 对比证书有效期
+ oldCertNotBefore, _ := time.Parse("2006-01-02T15:04:05Z", certItem.StartTime)
+ oldCertNotAfter, _ := time.Parse("2006-01-02T15:04:05Z", certItem.EndTime)
+ if !certX509.NotBefore.Equal(oldCertNotBefore) {
+ continue
+ } else if !certX509.NotAfter.Equal(oldCertNotAfter) {
+ continue
+ }
+
+ // 对比证书指纹
+ //
+ // 注意,虽然文档中描述为 MD5 摘要,但示例给出的是 SHA-1 摘要,因此这里都尝试对比一下
+ fingerprintMd5 := md5.Sum(certX509.Raw)
+ fingerprintMd5Hex := hex.EncodeToString(fingerprintMd5[:])
+ fingerprintSha1 := sha1.Sum(certX509.Raw)
+ fingerprintSha1Hex := hex.EncodeToString(fingerprintSha1[:])
+ if !strings.EqualFold(fingerprintMd5Hex, certItem.Fingerprint) && !strings.EqualFold(fingerprintSha1Hex, certItem.Fingerprint) {
+ continue
+ }
+
+ // 对比证书公钥算法
+ switch certX509.PublicKeyAlgorithm {
+ case x509.RSA:
+ if !strings.EqualFold(certItem.Algorithm, "RSA") {
+ continue
+ }
+ case x509.ECDSA:
+ if !strings.EqualFold(certItem.Algorithm, "EC") && !strings.EqualFold(certItem.Algorithm, "ECDSA") {
+ continue
+ }
+ default:
+ // 未知算法,跳过
+ continue
+ }
+
+ // 如果以上信息都一致,则视为已存在相同证书,直接返回
+ c.logger.Info("ssl certificate already exists")
+ return &certmgr.UploadResult{
+ CertId: certItem.CertificateId,
+ CertName: certItem.CertificateLabel,
+ }, nil
+ }
+
+ if len(describeCertificatesResp.Response.DataSet) < describeCertificatesPageSize {
+ break
+ }
+
+ describeCertificatesPageNum++
+ }
+
+ // 生成新证书名(需符合 Zenlayer 命名规则)
+ certName := fmt.Sprintf("certimate_%d", time.Now().UnixMilli())
+
+ // 创建证书
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/createcertificate
+ createCertificateReq := zga.NewCreateCertificateRequest()
+ createCertificateReq.ResourceGroupId = c.config.ResourceGroupId
+ createCertificateReq.CertificateLabel = certName
+ createCertificateReq.CertificateContent = certPEM
+ createCertificateReq.CertificateKey = privkeyPEM
+ createCertificateResp, err := c.sdkClient.CreateCertificate(createCertificateReq)
+ c.logger.Debug("sdk request 'zga.CreateCertificate'", slog.Any("request", createCertificateReq), slog.Any("response", createCertificateResp))
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute sdk request 'zga.CreateCertificate': %w", err)
+ }
+
+ return &certmgr.UploadResult{
+ CertId: createCertificateResp.Response.CertificateId,
+ CertName: certName,
+ }, nil
+}
+
+func (c *Certmgr) Replace(ctx context.Context, certIdOrName string, certPEM, privkeyPEM string) (*certmgr.OperateResult, error) {
+ // 更新证书
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/certificate/modifycertificate
+ modifyCertificateReq := zga.NewModifyCertificateRequest()
+ modifyCertificateReq.CertificateId = certIdOrName
+ modifyCertificateReq.CertificateContent = certPEM
+ modifyCertificateReq.CertificateKey = privkeyPEM
+ modifyCertificateResp, err := c.sdkClient.ModifyCertificate(modifyCertificateReq)
+ c.logger.Debug("sdk request 'zga.ModifyCertificate'", slog.Any("request", modifyCertificateReq), slog.Any("response", modifyCertificateResp))
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute sdk request 'zga.ModifyCertificate': %w", err)
+ }
+
+ return &certmgr.OperateResult{}, nil
+}
+
+func createSDKClient(accessKeyId, accessKeyPassword string) (*internal.ZgaClient, error) {
+ config := zcommon.NewConfig()
+
+ client, err := internal.NewZgaClient(config, accessKeyId, accessKeyPassword)
+ if err != nil {
+ return nil, err
+ }
+
+ return client, nil
+}
diff --git a/pkg/core/deployer/providers/zenlayer-ga/consts.go b/pkg/core/deployer/providers/zenlayer-ga/consts.go
new file mode 100644
index 000000000..f6208113b
--- /dev/null
+++ b/pkg/core/deployer/providers/zenlayer-ga/consts.go
@@ -0,0 +1,8 @@
+package zenlayerga
+
+const (
+ // 资源类型:部署到指定全球网络加速器。
+ RESOURCE_TYPE_ACCELERATOR = "accelerator"
+ // 资源类型:替换指定证书。
+ RESOURCE_TYPE_CERTIFICATE = "certificate"
+)
diff --git a/pkg/core/deployer/providers/zenlayer-ga/internal/client.go b/pkg/core/deployer/providers/zenlayer-ga/internal/client.go
new file mode 100644
index 000000000..8f29bde7b
--- /dev/null
+++ b/pkg/core/deployer/providers/zenlayer-ga/internal/client.go
@@ -0,0 +1,40 @@
+package internal
+
+import (
+ "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
+ zga "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706"
+)
+
+// This is a partial copy of https://github.com/zenlayer/zenlayercloud-sdk-go/blob/main/zenlayercloud/zga20230706/client.go
+// to lightweight the vendor packages in the built binary.
+type ZgaClient struct {
+ *common.Client
+}
+
+func NewZgaClient(config *common.Config, secretKeyId, secretKeyPassword string) (client *ZgaClient, err error) {
+ client = &ZgaClient{}
+
+ err = client.InitWithCredential(common.NewCredential(secretKeyId, secretKeyPassword))
+ if err != nil {
+ return nil, err
+ }
+
+ err = client.WithConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return client, nil
+}
+
+func (c *ZgaClient) DescribeAccelerators(request *zga.DescribeAcceleratorsRequest) (response *zga.DescribeAcceleratorsResponse, err error) {
+ response = zga.NewDescribeAcceleratorsResponse()
+ err = c.ApiCall(request, response)
+ return
+}
+
+func (c *ZgaClient) ModifyAcceleratorCertificate(request *zga.ModifyAcceleratorCertificateRequest) (response *zga.ModifyAcceleratorCertificateResponse, err error) {
+ response = zga.NewModifyAcceleratorCertificateResponse()
+ err = c.ApiCall(request, response)
+ return
+}
diff --git a/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go b/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go
new file mode 100644
index 000000000..d70fac326
--- /dev/null
+++ b/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga.go
@@ -0,0 +1,200 @@
+package zenlayerga
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ zcommon "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/common"
+ zga "github.com/zenlayer/zenlayercloud-sdk-go/zenlayercloud/zga20230706"
+
+ "github.com/certimate-go/certimate/pkg/core/certmgr"
+ mcertmgr "github.com/certimate-go/certimate/pkg/core/certmgr/providers/zenlayer-ga"
+ "github.com/certimate-go/certimate/pkg/core/deployer"
+ "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-ga/internal"
+ xwait "github.com/certimate-go/certimate/pkg/utils/wait"
+)
+
+type DeployerConfig struct {
+ // Zenlayer AccessKeyId。
+ AccessKeyId string `json:"accessKeyId"`
+ // Zenlayer AccessKeyPassword。
+ AccessKeyPassword string `json:"accessKeyPassword"`
+ // Zenlayer 资源组 ID。
+ ResourceGroupId string `json:"resourceGroupId,omitempty"`
+ // 部署资源类型。
+ ResourceType string `json:"resourceType"`
+ // 加速器 ID。
+ // 部署资源类型为 [RESOURCE_TYPE_ACCELERATOR] 时必填。
+ AcceleratorId string `json:"acceleratorId,omitempty"`
+ // 证书 ID。
+ // 部署资源类型为 [RESOURCE_TYPE_CERTIFICATE] 时必填。
+ CertificateId string `json:"certificateId,omitempty"`
+}
+
+type Deployer struct {
+ config *DeployerConfig
+ logger *slog.Logger
+ sdkClient *internal.ZgaClient
+ sdkCertmgr certmgr.Provider
+}
+
+var _ deployer.Provider = (*Deployer)(nil)
+
+func NewDeployer(config *DeployerConfig) (*Deployer, error) {
+ if config == nil {
+ return nil, errors.New("the configuration of the deployer provider is nil")
+ }
+
+ client, err := createSDKClient(config.AccessKeyId, config.AccessKeyPassword)
+ if err != nil {
+ return nil, fmt.Errorf("could not create client: %w", err)
+ }
+
+ pcertmgr, err := mcertmgr.NewCertmgr(&mcertmgr.CertmgrConfig{
+ AccessKeyId: config.AccessKeyId,
+ AccessKeyPassword: config.AccessKeyPassword,
+ ResourceGroupId: config.ResourceGroupId,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("could not create certmgr: %w", err)
+ }
+
+ return &Deployer{
+ config: config,
+ logger: slog.Default(),
+ sdkClient: client,
+ sdkCertmgr: pcertmgr,
+ }, nil
+}
+
+func (d *Deployer) SetLogger(logger *slog.Logger) {
+ if logger == nil {
+ d.logger = slog.New(slog.DiscardHandler)
+ } else {
+ d.logger = logger
+ }
+
+ d.sdkCertmgr.SetLogger(logger)
+}
+
+func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*deployer.DeployResult, error) {
+ // 根据部署资源类型决定部署方式
+ switch d.config.ResourceType {
+ case RESOURCE_TYPE_ACCELERATOR:
+ if err := d.deployToAccelerator(ctx, certPEM, privkeyPEM); err != nil {
+ return nil, err
+ }
+
+ case RESOURCE_TYPE_CERTIFICATE:
+ if err := d.deployToCertificate(ctx, certPEM, privkeyPEM); err != nil {
+ return nil, err
+ }
+
+ default:
+ return nil, fmt.Errorf("unsupported resource type '%s'", d.config.ResourceType)
+ }
+
+ return &deployer.DeployResult{}, nil
+}
+
+func (d *Deployer) deployToAccelerator(ctx context.Context, certPEM, privkeyPEM string) error {
+ if d.config.AcceleratorId == "" {
+ return errors.New("config `acceleratorId` is required")
+ }
+
+ // 上传证书
+ upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM)
+ if err != nil {
+ return fmt.Errorf("failed to upload certificate file: %w", err)
+ } else {
+ d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
+ }
+
+ // 查询加速器信息
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/describeaccelerators
+ describeAcceleratorsReq := zga.NewDescribeAcceleratorsRequest()
+ describeAcceleratorsReq.AcceleratorIds = []string{d.config.AcceleratorId}
+ describeAcceleratorsReq.PageNum = 1
+ describeAcceleratorsReq.PageSize = 1
+ describeAcceleratorsResp, err := d.sdkClient.DescribeAccelerators(describeAcceleratorsReq)
+ d.logger.Debug("sdk request 'zga.DescribeAccelerators'", slog.Any("request", describeAcceleratorsReq), slog.Any("response", describeAcceleratorsResp))
+ if err != nil {
+ return fmt.Errorf("failed to execute sdk request 'zga.DescribeAccelerators': %w", err)
+ } else if len(describeAcceleratorsResp.Response.DataSet) == 0 {
+ return fmt.Errorf("could not found accelerator '%s'", d.config.AcceleratorId)
+ }
+
+ // 修改加速器证书
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/modifyacceleratorcertificate
+ acceleratorInfo := describeAcceleratorsResp.Response.DataSet[0]
+ if acceleratorInfo.Certificate == nil || acceleratorInfo.Certificate.CertificateId != upres.CertId {
+ modifyAcceleratorCertificateReq := zga.NewModifyAcceleratorCertificateRequest()
+ modifyAcceleratorCertificateReq.AcceleratorId = acceleratorInfo.AcceleratorId
+ modifyAcceleratorCertificateReq.CertificateId = upres.CertId
+ modifyAcceleratorCertificateResp, err := d.sdkClient.ModifyAcceleratorCertificate(modifyAcceleratorCertificateReq)
+ d.logger.Debug("sdk request 'zga.ModifyAcceleratorCertificate'", slog.Any("request", modifyAcceleratorCertificateReq), slog.Any("response", modifyAcceleratorCertificateResp))
+ if err != nil {
+ return fmt.Errorf("failed to execute sdk request 'zga.ModifyAcceleratorCertificate': %w", err)
+ }
+ }
+
+ // 查询加速器状态,等待部署状态变更
+ // REF: https://docs.console.zenlayer.com/api-reference/cn/networking/zga/accelerator/describeaccelerators
+ if _, err := xwait.UntilWithContext(ctx, func(_ context.Context, _ int) (bool, error) {
+ describeAcceleratorsReq := zga.NewDescribeAcceleratorsRequest()
+ describeAcceleratorsReq.AcceleratorIds = []string{acceleratorInfo.AcceleratorId}
+ describeAcceleratorsReq.PageNum = 1
+ describeAcceleratorsReq.PageSize = 1
+ describeAcceleratorsResp, err := d.sdkClient.DescribeAccelerators(describeAcceleratorsReq)
+ d.logger.Debug("sdk request 'zga.DescribeAccelerators'", slog.Any("request", describeAcceleratorsReq), slog.Any("response", describeAcceleratorsResp))
+ if err != nil {
+ return false, fmt.Errorf("failed to execute sdk request 'zga.DescribeAccelerators': %w", err)
+ } else if len(describeAcceleratorsResp.Response.DataSet) == 0 {
+ return false, fmt.Errorf("could not found accelerator '%s'", d.config.AcceleratorId)
+ }
+
+ switch describeAcceleratorsResp.Response.DataSet[0].AcceleratorStatus {
+ case "Accelerating":
+ return true, nil
+ case "NotAccelerate", "StopAccelerate", "AccelerateFailure":
+ return false, fmt.Errorf("unexpected zenlayer accelerator status")
+ }
+
+ d.logger.Info("waiting for zenlayer accelerator deploying completion ...")
+ return false, nil
+ }, time.Second*5); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (d *Deployer) deployToCertificate(ctx context.Context, certPEM, privkeyPEM string) error {
+ if d.config.CertificateId == "" {
+ return errors.New("config `certificateId` is required")
+ }
+
+ // 替换证书
+ upres, err := d.sdkCertmgr.Replace(ctx, d.config.CertificateId, certPEM, privkeyPEM)
+ if err != nil {
+ return fmt.Errorf("failed to replace certificate file: %w", err)
+ } else {
+ d.logger.Info("ssl certificate replaced", slog.Any("result", upres))
+ }
+
+ return nil
+}
+
+func createSDKClient(accessKeyId, accessKeyPassword string) (*internal.ZgaClient, error) {
+ config := zcommon.NewConfig()
+
+ client, err := internal.NewZgaClient(config, accessKeyId, accessKeyPassword)
+ if err != nil {
+ return nil, err
+ }
+
+ return client, nil
+}
diff --git a/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga_test.go b/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga_test.go
new file mode 100644
index 000000000..8f3e6c2ef
--- /dev/null
+++ b/pkg/core/deployer/providers/zenlayer-ga/zenlayer_ga_test.go
@@ -0,0 +1,111 @@
+package zenlayerga_test
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ provider "github.com/certimate-go/certimate/pkg/core/deployer/providers/zenlayer-ga"
+)
+
+var (
+ fInputCertPath string
+ fInputKeyPath string
+ fAccessKeyId string
+ fAccessKeyPassword string
+ fAcceleratorId string
+ fCertificateId string
+)
+
+func init() {
+ argsPrefix := "ZENLAYERGA_"
+
+ flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "")
+ flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "")
+ flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "")
+ flag.StringVar(&fAccessKeyPassword, argsPrefix+"ACCESSKEYPASSWORD", "", "")
+ flag.StringVar(&fAcceleratorId, argsPrefix+"ACCELERATORID", "", "")
+ flag.StringVar(&fCertificateId, argsPrefix+"CERTIFICATEID", "", "")
+}
+
+/*
+Shell command to run this test:
+
+ go test -v ./zenlayer_ga_test.go -args \
+ --ZENLAYERGA_INPUTCERTPATH="/path/to/your-input-cert.pem" \
+ --ZENLAYERGA_INPUTKEYPATH="/path/to/your-input-key.pem" \
+ --ZENLAYERGA_ACCESSKEYID="your-access-key-id" \
+ --ZENLAYERGA_ACCESSKEYPASSWORD="your-access-key-password" \
+ --ZENLAYERGA_ACCELERATORID="your-ga-accelerator-id" \
+ --ZENLAYERGA_CERTIFICATEID="your-ga-certificate-id"
+*/
+func TestDeploy(t *testing.T) {
+ flag.Parse()
+
+ t.Run("Deploy_ToAccelerator", func(t *testing.T) {
+ t.Log(strings.Join([]string{
+ "args:",
+ fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
+ fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
+ fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId),
+ fmt.Sprintf("ACCESSKEYPASSWORD: %v", fAccessKeyPassword),
+ fmt.Sprintf("ACCELERATORID: %v", fAcceleratorId),
+ }, "\n"))
+
+ provider, err := provider.NewDeployer(&provider.DeployerConfig{
+ AccessKeyId: fAccessKeyId,
+ AccessKeyPassword: fAccessKeyPassword,
+ ResourceType: provider.RESOURCE_TYPE_ACCELERATOR,
+ AcceleratorId: fAcceleratorId,
+ })
+ if err != nil {
+ t.Errorf("err: %+v", err)
+ return
+ }
+
+ fInputCertData, _ := os.ReadFile(fInputCertPath)
+ fInputKeyData, _ := os.ReadFile(fInputKeyPath)
+ res, err := provider.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData))
+ if err != nil {
+ t.Errorf("err: %+v", err)
+ return
+ }
+
+ t.Logf("ok: %v", res)
+ })
+
+ t.Run("Deploy_ToCertificate", func(t *testing.T) {
+ t.Log(strings.Join([]string{
+ "args:",
+ fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
+ fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
+ fmt.Sprintf("ACCESSKEYID: %v", fAccessKeyId),
+ fmt.Sprintf("ACCESSKEYPASSWORD: %v", fAccessKeyPassword),
+ fmt.Sprintf("CERTIFICATEID: %v", fCertificateId),
+ }, "\n"))
+
+ provider, err := provider.NewDeployer(&provider.DeployerConfig{
+ AccessKeyId: fAccessKeyId,
+ AccessKeyPassword: fAccessKeyPassword,
+ ResourceType: provider.RESOURCE_TYPE_CERTIFICATE,
+ CertificateId: fCertificateId,
+ })
+ if err != nil {
+ t.Errorf("err: %+v", err)
+ return
+ }
+
+ fInputCertData, _ := os.ReadFile(fInputCertPath)
+ fInputKeyData, _ := os.ReadFile(fInputKeyPath)
+ res, err := provider.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData))
+ if err != nil {
+ t.Errorf("err: %+v", err)
+ return
+ }
+
+ t.Logf("ok: %v", res)
+ })
+}
diff --git a/ui/public/imgs/providers/zenlayer.svg b/ui/public/imgs/providers/zenlayer.svg
new file mode 100644
index 000000000..2f1d841fa
--- /dev/null
+++ b/ui/public/imgs/providers/zenlayer.svg
@@ -0,0 +1 @@
+
diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx
index b45043a99..002c1294c 100644
--- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx
+++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx
@@ -111,6 +111,7 @@ import AccessConfigFieldsProviderWebhook from "./AccessConfigFieldsProviderWebho
import AccessConfigFieldsProviderWeComBot from "./AccessConfigFieldsProviderWeComBot";
import AccessConfigFieldsProviderWestcn from "./AccessConfigFieldsProviderWestcn";
import AccessConfigFieldsProviderXinnet from "./AccessConfigFieldsProviderXinnet";
+import AccessConfigFieldsProviderZenlayer from "./AccessConfigFieldsProviderZenlayer";
import AccessConfigFieldsProviderZeroSSL from "./AccessConfigFieldsProviderZeroSSL";
const providerComponentMap: Partial>> = {
@@ -227,6 +228,7 @@ const providerComponentMap: Partial {
+ const { i18n, t } = useTranslation();
+
+ const { parentNamePath } = useFormNestedFieldsContext();
+ const formSchema = z.object({
+ [parentNamePath]: getSchema({ i18n }),
+ });
+ const formRule = createSchemaFieldRule(formSchema);
+ const initialValues = getInitialValues();
+
+ return (
+ <>
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+ >
+ );
+};
+
+const getInitialValues = (): Nullish>> => {
+ return {
+ accessKeyId: "",
+ accessKeyPassword: "",
+ };
+};
+
+const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => {
+ const { t } = i18n;
+
+ return z.object({
+ accessKeyId: z.string().nonempty(t("access.form.zenlayer_access_key_id.placeholder")),
+ accessKeyPassword: z.string().nonempty(t("access.form.zenlayer_access_key_password.placeholder")),
+ resourceGroupId: z.string().nullish(),
+ });
+};
+
+const _default = Object.assign(AccessConfigFormFieldsProviderZenlayer, {
+ getInitialValues,
+ getSchema,
+});
+
+export default _default;
diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx
index f4d8111e8..3591f40b9 100644
--- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx
+++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx
@@ -122,6 +122,7 @@ import BizDeployNodeConfigFieldsProviderWangsuCDN from "./BizDeployNodeConfigFie
import BizDeployNodeConfigFieldsProviderWangsuCDNPro from "./BizDeployNodeConfigFieldsProviderWangsuCDNPro";
import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeConfigFieldsProviderWangsuCertificate";
import BizDeployNodeConfigFieldsProviderWebhook from "./BizDeployNodeConfigFieldsProviderWebhook";
+import BizDeployNodeConfigFieldsProviderZenlayerGA from "./BizDeployNodeConfigFieldsProviderZenlayerGA";
const providerComponentMap: Partial>> = {
/*
@@ -248,6 +249,7 @@ const providerComponentMap: Partial any; deps?: unknown[] }) => {
diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx
new file mode 100644
index 000000000..234d0ac25
--- /dev/null
+++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderZenlayerGA.tsx
@@ -0,0 +1,120 @@
+import { getI18n, useTranslation } from "react-i18next";
+import { Form, Input, Select } from "antd";
+import { createSchemaFieldRule } from "antd-zod";
+import { z } from "zod";
+
+import Show from "@/components/Show";
+
+import { useFormNestedFieldsContext } from "./_context";
+
+const RESOURCE_TYPE_ACCELERATOR = "accelerator" as const;
+const RESOURCE_TYPE_CERTIFICATE = "certificate" as const;
+
+const BizDeployNodeConfigFieldsProviderZenlayerGA = () => {
+ const { i18n, t } = useTranslation();
+
+ const { parentNamePath } = useFormNestedFieldsContext();
+ const formSchema = z.object({
+ [parentNamePath]: getSchema({ i18n }),
+ });
+ const formRule = createSchemaFieldRule(formSchema);
+ const formInst = Form.useFormInstance();
+ const initialValues = getInitialValues();
+
+ const fieldResourceType = Form.useWatch([parentNamePath, "resourceType"], formInst);
+
+ return (
+ <>
+
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+
+
+ >
+ );
+};
+
+const getInitialValues = (): Nullish>> => {
+ return {
+ resourceType: RESOURCE_TYPE_ACCELERATOR,
+ acceleratorId: "",
+ };
+};
+
+const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => {
+ const { t } = i18n;
+
+ return z
+ .object({
+ resourceType: z.literal([RESOURCE_TYPE_ACCELERATOR, RESOURCE_TYPE_CERTIFICATE], t("workflow_node.deploy.form.shared_resource_type.placeholder")),
+ acceleratorId: z.string().nullish(),
+ certificateId: z.string().nullish(),
+ })
+ .superRefine((values, ctx) => {
+ switch (values.resourceType) {
+ case RESOURCE_TYPE_ACCELERATOR:
+ {
+ if (!values.acceleratorId?.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ message: t("workflow_node.deploy.form.zenlayer_ga_accelerator_id.placeholder"),
+ path: ["acceleratorId"],
+ });
+ }
+ }
+ break;
+
+ case RESOURCE_TYPE_CERTIFICATE:
+ {
+ if (!values.certificateId?.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ message: t("workflow_node.deploy.form.zenlayer_ga_certificate_id.placeholder"),
+ path: ["certificateId"],
+ });
+ }
+ }
+ break;
+ }
+ });
+};
+
+const _default = Object.assign(BizDeployNodeConfigFieldsProviderZenlayerGA, {
+ getInitialValues,
+ getSchema,
+});
+
+export default _default;
diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts
index a72fee84e..26cbf164e 100644
--- a/ui/src/domain/provider.ts
+++ b/ui/src/domain/provider.ts
@@ -127,6 +127,7 @@ export const ACCESS_PROVIDERS = Object.freeze({
WECOMBOT: "wecombot",
WESTCN: "westcn",
XINNET: "xinnet",
+ ZENLAYER: "zenlayer",
ZEROSSL: "zerossl",
} as const);
@@ -186,6 +187,7 @@ export const accessProvidersMap: Maphttps://apidoc.xin.cn/doc-7283837",
+ "access.form.zenlayer_access_key_id.label": "Zenlayer AccessKeyID",
+ "access.form.zenlayer_access_key_id.placeholder": "Please enter Zenlayer AccessKeyID",
+ "access.form.zenlayer_access_key_id.tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/generate-an-api-access-key",
+ "access.form.zenlayer_access_key_password.label": "Zenlayer AccessKeyPassword",
+ "access.form.zenlayer_access_key_password.placeholder": "Please enter Zenlayer AccessKeyPassword",
+ "access.form.zenlayer_access_key_password.tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/generate-an-api-access-key",
+ "access.form.zenlayer_resource_group_id.label": "Zenlayer resource group ID (Optional)",
+ "access.form.zenlayer_resource_group_id.placeholder": "Please enter Zenlayer resource group ID",
+ "access.form.zenlayer_resource_group_id.tooltip": "For more information, see https://docs.console.zenlayer.com/welcome/platform/team-management/create-a-resource-group",
"access.form.zerossl_eab.guide": "Learn more about using EAB key in ZeroSSL:
https://zerossl.com/documentation/acme/"
}
diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json
index 210ebb7a1..92e63ba06 100644
--- a/ui/src/i18n/locales/en/nls.provider.json
+++ b/ui/src/i18n/locales/en/nls.provider.json
@@ -235,6 +235,8 @@
"provider.westcn": "West.cn",
"provider.xinnet": "Xinnet.com",
"provider.zerossl": "ZeroSSL",
+ "provider.zenlayer": "Zenlayer",
+ "provider.zenlayer_ga": "Zenlayer - ZGA (Global Accelerator)",
"provider.category.all": "All",
"provider.category.cdn": "CDN",
diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json
index 4b9c19ac3..d7282395c 100644
--- a/ui/src/i18n/locales/en/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json
@@ -1265,6 +1265,14 @@
"workflow_node.deploy.form.webhook_timeout.label": "Webhook timeout (Optional)",
"workflow_node.deploy.form.webhook_timeout.placeholder": "Please enter Webhook timeout",
"workflow_node.deploy.form.webhook_timeout.unit": "seconds",
+ "workflow_node.deploy.form.zenlayer_ga_resource_type.option.accelerator.label": "Accelerator",
+ "workflow_node.deploy.form.zenlayer_ga_resource_type.option.certificate.label": "Certificate",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.label": "Zenlayer ZGA accelerator ID",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.placeholder": "Please enter Zenlayer ZGA accelerator ID",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.tooltip": "For more information, https://console.zenlayer.com/gia/",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.label": "Zenlayer ZGA certificate ID",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.placeholder": "Please enter Zenlayer ZGA certificate ID",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.tooltip": "For more information, https://console.zenlayer.com/gia/",
"workflow_node.deploy.form.skip_on_last_succeeded.label": "Repeated deployment",
"workflow_node.deploy.form.skip_on_last_succeeded.prefix": "If the last deployment was successful, ",
"workflow_node.deploy.form.skip_on_last_succeeded.suffix": " re-deployment.",
diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json
index f0ec5844e..d395ee915 100644
--- a/ui/src/i18n/locales/zh/nls.access.json
+++ b/ui/src/i18n/locales/zh/nls.access.json
@@ -747,5 +747,14 @@
"access.form.xinnet_api_password.label": "新网数码代理商 API 密码",
"access.form.xinnet_api_password.placeholder": "请输入新网数码代理商 API 密码",
"access.form.xinnet_agent.guide": "新网数码 API 仅支持代理商调用。点击下方链接了解更多:
https://apidoc.xin.cn/doc-7283837",
+ "access.form.zenlayer_access_key_id.label": "Zenlayer AccessKeyID",
+ "access.form.zenlayer_access_key_id.placeholder": "请输入 Zenlayer AccessKeyID",
+ "access.form.zenlayer_access_key_id.tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/generate-an-api-access-key",
+ "access.form.zenlayer_access_key_password.label": "Zenlayer AccessKeyPassword",
+ "access.form.zenlayer_access_key_password.placeholder": "请输入 Zenlayer AccessKeyPassword",
+ "access.form.zenlayer_access_key_password.tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/generate-an-api-access-key",
+ "access.form.zenlayer_resource_group_id.label": "Zenlayer 资源组 ID(可选)",
+ "access.form.zenlayer_resource_group_id.placeholder": "请输入 Zenlayer 资源组 ID",
+ "access.form.zenlayer_resource_group_id.tooltip": "这是什么?请参阅 https://docs.console.zenlayer.com/welcome/cn/platform/team-management/create-a-resource-group",
"access.form.zerossl_eab.guide": "点击下方链接了解如何获取 ZeroSSL EAB:
https://zerossl.com/documentation/acme/"
}
diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json
index 6f4851ab3..241215385 100644
--- a/ui/src/i18n/locales/zh/nls.provider.json
+++ b/ui/src/i18n/locales/zh/nls.provider.json
@@ -235,6 +235,8 @@
"provider.westcn": "西部数码",
"provider.xinnet": "新网数码",
"provider.zerossl": "ZeroSSL",
+ "provider.zenlayer": "Zenlayer",
+ "provider.zenlayer_ga": "Zenlayer - 全球网络加速器 ZGA",
"provider.category.all": "全部",
"provider.category.cdn": "CDN",
diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
index 2bb8b3d92..63f8054f2 100644
--- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
@@ -1263,6 +1263,14 @@
"workflow_node.deploy.form.webhook_timeout.label": "Webhook 超时时间(可选)",
"workflow_node.deploy.form.webhook_timeout.placeholder": "请输入 Webhook 超时时间",
"workflow_node.deploy.form.webhook_timeout.unit": "秒",
+ "workflow_node.deploy.form.zenlayer_ga_resource_type.option.accelerator.label": "部署到指定全球网络加速器",
+ "workflow_node.deploy.form.zenlayer_ga_resource_type.option.certificate.label": "替换指定证书",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.label": "Zenlayer ZGA 加速器 ID",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.placeholder": "请输入 Zenlayer ZGA 加速器 ID",
+ "workflow_node.deploy.form.zenlayer_ga_accelerator_id.tooltip": "这是什么?请参阅 https://console.zenlayer.com/gia/",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.label": "Zenlayer ZGA 证书 ID",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.placeholder": "请输入 Zenlayer ZGA 证书 ID",
+ "workflow_node.deploy.form.zenlayer_ga_certificate_id.tooltip": "这是什么?请参阅 https://console.zenlayer.com/gia/",
"workflow_node.deploy.form.skip_on_last_succeeded.label": "重复部署",
"workflow_node.deploy.form.skip_on_last_succeeded.prefix": "当上次部署相同证书成功后,再次运行工作流时",
"workflow_node.deploy.form.skip_on_last_succeeded.suffix": "此部署节点。",