diff --git a/internal/certmgmt/deployers/sp_volcengine_vod.go b/internal/certmgmt/deployers/sp_volcengine_vod.go new file mode 100644 index 00000000..40a6fee9 --- /dev/null +++ b/internal/certmgmt/deployers/sp_volcengine_vod.go @@ -0,0 +1,29 @@ +package deployers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core/deployer" + volcenginevod "github.com/certimate-go/certimate/pkg/core/deployer/providers/volcengine-vod" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + Registries.MustRegister(domain.DeploymentProviderTypeVolcEngineVOD, func(options *ProviderFactoryOptions) (deployer.Provider, error) { + credentials := domain.AccessConfigForVolcEngine{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + provider, err := volcenginevod.NewDeployer(&volcenginevod.DeployerConfig{ + AccessKeyId: credentials.AccessKeyId, + AccessKeySecret: credentials.SecretAccessKey, + SpaceName: xmaps.GetString(options.ProviderExtendedConfig, "spaceName"), + DomainMatchPattern: xmaps.GetString(options.ProviderExtendedConfig, "domainMatchPattern"), + DomainType: xmaps.GetString(options.ProviderExtendedConfig, "domainType"), + Domain: xmaps.GetString(options.ProviderExtendedConfig, "domain"), + }) + return provider, err + }) +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 8e310c64..05e8fbd7 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -349,6 +349,7 @@ const ( DeploymentProviderTypeVolcEngineImageX = DeploymentProviderType(AccessProviderTypeVolcEngine + "-imagex") DeploymentProviderTypeVolcEngineLive = DeploymentProviderType(AccessProviderTypeVolcEngine + "-live") DeploymentProviderTypeVolcEngineTOS = DeploymentProviderType(AccessProviderTypeVolcEngine + "-tos") + DeploymentProviderTypeVolcEngineVOD = DeploymentProviderType(AccessProviderTypeVolcEngine + "-vod") DeploymentProviderTypeWangsuCDN = DeploymentProviderType(AccessProviderTypeWangsu + "-cdn") DeploymentProviderTypeWangsuCDNPro = DeploymentProviderType(AccessProviderTypeWangsu + "-cdnpro") DeploymentProviderTypeWangsuCertificate = DeploymentProviderType(AccessProviderTypeWangsu + "-certificate") diff --git a/pkg/core/deployer/providers/volcengine-vod/consts.go b/pkg/core/deployer/providers/volcengine-vod/consts.go new file mode 100644 index 00000000..943ac98e --- /dev/null +++ b/pkg/core/deployer/providers/volcengine-vod/consts.go @@ -0,0 +1,17 @@ +package volcenginevod + +const ( + // 匹配模式:精确匹配。 + DOMAIN_MATCH_PATTERN_EXACT = "exact" + // 匹配模式:通配符匹配。 + DOMAIN_MATCH_PATTERN_WILDCARD = "wildcard" + // 匹配模式:证书 SAN 匹配。 + DOMAIN_MATCH_PATTERN_CERTSAN = "certsan" +) + +const ( + // 域名类型:点播加速域名。 + DOMAIN_TYPE_PLAY = "play" + // 域名类型:封面加速域名。 + DOMAIN_TYPE_IMAGE = "image" +) diff --git a/pkg/core/deployer/providers/volcengine-vod/volcengine_vod.go b/pkg/core/deployer/providers/volcengine-vod/volcengine_vod.go new file mode 100644 index 00000000..2015bbd1 --- /dev/null +++ b/pkg/core/deployer/providers/volcengine-vod/volcengine_vod.go @@ -0,0 +1,260 @@ +package volcenginevod + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/samber/lo" + "github.com/volcengine/volc-sdk-golang/service/vod" + "github.com/volcengine/volc-sdk-golang/service/vod/models/business" + ve "github.com/volcengine/volcengine-go-sdk/volcengine" + + "github.com/certimate-go/certimate/pkg/core/certmgr" + mcertmgr "github.com/certimate-go/certimate/pkg/core/certmgr/providers/volcengine-certcenter" + "github.com/certimate-go/certimate/pkg/core/deployer" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xcerthostname "github.com/certimate-go/certimate/pkg/utils/cert/hostname" + "github.com/volcengine/volc-sdk-golang/service/vod/models/request" +) + +type DeployerConfig struct { + // 火山引擎 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 火山引擎 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 点播空间名称。 + SpaceName string `json:"spaceName"` + // 域名匹配模式。 + // 零值时默认值 [DOMAIN_MATCH_PATTERN_EXACT]。 + DomainMatchPattern string `json:"domainMatchPattern,omitempty"` + // 点播域名类型。 + DomainType string `json:"domainType"` + // 点播加速域名(支持泛域名)。 + Domain string `json:"domain"` +} + +type Deployer struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *vod.Vod + 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 := vod.NewInstance() + client.SetAccessKey(config.AccessKeyId) + client.SetSecretKey(config.AccessKeySecret) + + pcertmgr, err := mcertmgr.NewCertmgr(&mcertmgr.CertmgrConfig{ + AccessKeyId: config.AccessKeyId, + AccessKeySecret: config.AccessKeySecret, + }) + 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) { + // 上传证书 + upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } else { + d.logger.Info("ssl certificate uploaded", slog.Any("result", upres)) + } + + // 获取待部署的域名 + domains := make([]string, 0) + switch d.config.DomainMatchPattern { + case "", DOMAIN_MATCH_PATTERN_EXACT: + { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + domains = append(domains, d.config.Domain) + } + + case DOMAIN_MATCH_PATTERN_WILDCARD: + { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + if strings.HasPrefix(d.config.Domain, "*.") { + domainCandidates, err := d.getAllDomains(ctx) + if err != nil { + return nil, err + } + + domains = lo.Filter(domainCandidates, func(domain string, _ int) bool { + return xcerthostname.IsMatch(d.config.Domain, domain) + }) + if len(domains) == 0 { + return nil, errors.New("could not find any domains matched by wildcard") + } + } else { + domains = append(domains, d.config.Domain) + } + } + + case DOMAIN_MATCH_PATTERN_CERTSAN: + { + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, err + } + + domainCandidates, err := d.getAllDomains(ctx) + if err != nil { + return nil, err + } + + domains = lo.Filter(domainCandidates, func(domain string, _ int) bool { + return certX509.VerifyHostname(domain) == nil + }) + if len(domains) == 0 { + return nil, errors.New("could not find any domains matched by certificate") + } + } + + default: + return nil, fmt.Errorf("unsupported domain match pattern: '%s'", d.config.DomainMatchPattern) + } + + // 遍历更新域名证书 + if len(domains) == 0 { + d.logger.Info("no vod domains to deploy") + } else { + d.logger.Info("found vod domains to deploy", slog.Any("domains", domains)) + var errs []error + + for _, domain := range domains { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + if err := d.updateDomainCertificate(ctx, domain, upres.CertId); err != nil { + errs = append(errs, err) + } + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + } + + return &deployer.DeployResult{}, nil +} + +func (d *Deployer) getAllDomains(ctx context.Context) ([]string, error) { + domains := make([]string, 0) + + // 获取空间域名列表 + // REF: https://www.volcengine.com/docs/4/106062 + listDomainDetailOffset := 0 + listDomainDetailLimit := 1000 + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + listDomainReq := &request.VodListDomainRequest{ + SpaceName: d.config.SpaceName, + DomainType: d.config.DomainType, + SourceStationType: 1, + Offset: int32(listDomainDetailOffset), + Limit: int32(listDomainDetailLimit), + } + listDomainResp, _, err := d.sdkClient.ListDomain(listDomainReq) + d.logger.Debug("sdk request 'vod.ListDomain'", slog.Any("request", listDomainReq), slog.Any("response", listDomainResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'vod.ListDomain': %w", err) + } + + if listDomainResp.Result == nil { + break + } + + var byteInstances []*business.VodDomainInstanceInfo + switch d.config.DomainType { + case DOMAIN_TYPE_PLAY: + byteInstances = listDomainResp.GetResult().GetPlayInstanceInfo().GetByteInstances() + case DOMAIN_TYPE_IMAGE: + byteInstances = listDomainResp.GetResult().GetImageInstanceInfo().GetByteInstances() + default: + return nil, fmt.Errorf("unsupported domain type: '%s'", d.config.DomainType) + } + + for _, byteDomains := range byteInstances { + if byteDomains.Domains == nil { + break + } + for _, domainItem := range byteDomains.Domains { + domains = append(domains, domainItem.Domain) + } + } + + if listDomainResp.Result.Total <= int64(listDomainDetailOffset+listDomainDetailLimit) { + break + } + + listDomainDetailOffset += listDomainDetailLimit + } + + return domains, nil +} + +func (d *Deployer) updateDomainCertificate(ctx context.Context, domain string, cloudCertId string) error { + // 更新域名配置 + // REF: https://www.volcengine.com/docs/4/1317310 + updateDomainConfigReq := &request.VodUpdateDomainConfigRequest{ + SpaceName: d.config.SpaceName, + DomainType: d.config.DomainType, + Domain: domain, + Config: &business.VodDomainConfig{ + HTTPS: &business.HTTPS{ + Switch: ve.Bool(true), + CertInfo: &business.CertInfo{ + CertId: &cloudCertId, + }, + }, + }, + } + updateDomainConfigResp, _, err := d.sdkClient.UpdateDomainConfig(updateDomainConfigReq) + d.logger.Debug("sdk request 'vod.UpdateDomainConfig'", slog.Any("request", updateDomainConfigReq), slog.Any("response", updateDomainConfigResp)) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/core/deployer/providers/volcengine-vod/volcengine_vod_test.go b/pkg/core/deployer/providers/volcengine-vod/volcengine_vod_test.go new file mode 100644 index 00000000..ed1b6b44 --- /dev/null +++ b/pkg/core/deployer/providers/volcengine-vod/volcengine_vod_test.go @@ -0,0 +1,86 @@ +package volcenginevod_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/certimate-go/certimate/pkg/core/deployer/providers/volcengine-vod" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fSpaceName string + fDomainType string + fDomain string +) + +func init() { + argsPrefix := "VOLCENGINEVOD_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fSpaceName, argsPrefix+"SPACENAME", "", "") + flag.StringVar(&fDomainType, argsPrefix+"DOMAINTYPE", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v ./volcengine_vod_test.go -args \ + --VOLCENGINEVOD_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --VOLCENGINEVOD_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --VOLCENGINEVOD_ACCESSKEYID="your-access-key-id" \ + --VOLCENGINEVOD_ACCESSKEYSECRET="your-access-key-secret" \ + --VOLCENGINEVOD_SPACENAME="vod-space-name" \ + --VOLCENGINEVOD_DOMAINTYPE="play" \ + --VOLCENGINEVOD_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", 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("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("SPACENAME: %v", fSpaceName), + fmt.Sprintf("DOMAINTYPE: %v", fDomainType), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + provider, err := provider.NewDeployer(&provider.DeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + DomainMatchPattern: provider.DOMAIN_MATCH_PATTERN_EXACT, + SpaceName: fSpaceName, + DomainType: fDomainType, + Domain: fDomain, + }) + 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/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 53d88538..8640d9f7 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -96,6 +96,7 @@ import BizDeployNodeConfigFieldsProviderVolcEngineDCDN from "./BizDeployNodeConf import BizDeployNodeConfigFieldsProviderVolcEngineImageX from "./BizDeployNodeConfigFieldsProviderVolcEngineImageX"; import BizDeployNodeConfigFieldsProviderVolcEngineLive from "./BizDeployNodeConfigFieldsProviderVolcEngineLive"; import BizDeployNodeConfigFieldsProviderVolcEngineTOS from "./BizDeployNodeConfigFieldsProviderVolcEngineTOS"; +import BizDeployNodeConfigFieldsProviderVolcEngineVOD from "./BizDeployNodeConfigFieldsProviderVolcEngineVOD.tsx"; import BizDeployNodeConfigFieldsProviderWangsuCDN from "./BizDeployNodeConfigFieldsProviderWangsuCDN"; import BizDeployNodeConfigFieldsProviderWangsuCDNPro from "./BizDeployNodeConfigFieldsProviderWangsuCDNPro"; import BizDeployNodeConfigFieldsProviderWangsuCertificate from "./BizDeployNodeConfigFieldsProviderWangsuCertificate"; @@ -200,6 +201,7 @@ const providerComponentMap: Partial { + 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 fieldDomainMatchPattern = Form.useWatch([parentNamePath, "domainMatchPattern"], { + form: formInst, + preserve: true, + }); + + return ( + <> + } + > + + + + + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + spaceName: "", + domainMatchPattern: DOMAIN_MATCH_PATTERN_EXACT, + domainType: DOMAIN_TYPE_PLAY, + domain: "", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z + .object({ + spaceName: z.string().nonempty(t("workflow_node.deploy.form.volcengine_vod_space_name.placeholder")).nullish(), + domainMatchPattern: z.string().nonempty(t("workflow_node.deploy.form.shared_domain_match_pattern.placeholder")).default(DOMAIN_MATCH_PATTERN_EXACT), + domainType: z.literal([DOMAIN_TYPE_PLAY, DOMAIN_TYPE_IMAGE], t("workflow_node.deploy.form.volcengine_vod_domain_type.placeholder")), + domain: z.string().nullish(), + }) + .superRefine((values, ctx) => { + if (values.domainMatchPattern) { + switch (values.domainMatchPattern) { + case DOMAIN_MATCH_PATTERN_EXACT: + case DOMAIN_MATCH_PATTERN_WILDCARD: + { + if (!validDomainName(values.domain!, { allowWildcard: true })) { + ctx.addIssue({ + code: "custom", + message: t("common.errmsg.domain_invalid"), + path: ["domain"], + }); + } + } + break; + } + } + }); +}; + +const _default = Object.assign(BizDeployNodeConfigFieldsProviderVolcEngineVOD, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 38a903eb..50257a1c 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -630,6 +630,7 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ VOLCENGINE_IMAGEX: `${ACCESS_PROVIDERS.VOLCENGINE}-imagex`, VOLCENGINE_LIVE: `${ACCESS_PROVIDERS.VOLCENGINE}-live`, VOLCENGINE_TOS: `${ACCESS_PROVIDERS.VOLCENGINE}-tos`, + VOLCENGINE_VOD: `${ACCESS_PROVIDERS.VOLCENGINE}-vod`, WANGSU_CDN: `${ACCESS_PROVIDERS.WANGSU}-cdn`, WANGSU_CDNPRO: `${ACCESS_PROVIDERS.WANGSU}-cdnpro`, WANGSU_CERTIFICATE: `${ACCESS_PROVIDERS.WANGSU}-certificate`, @@ -715,6 +716,7 @@ export const deploymentProvidersMap: Maphttps://console.volcengine.com/vod/overview", + "workflow_node.deploy.form.volcengine_vod_domain_type.label": "VolcEngine VOD domain type", + "workflow_node.deploy.form.volcengine_vod_domain_type.placeholder": "Please select VolcEngine VOD domain type", + "workflow_node.deploy.form.volcengine_vod_domain_type.option.play.label" : "Play", + "workflow_node.deploy.form.volcengine_vod_domain_type.option.image.label" : "Image", + "workflow_node.deploy.form.volcengine_vod_domain.label": "VolcEngine VOD domain", + "workflow_node.deploy.form.volcengine_vod_domain.placeholder": "Please enter VolcEngine VOD domain name", "workflow_node.deploy.form.wangsu_cdn_domains.label": "Wangsu Cloud CDN domains", "workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "Please enter Wangsu Cloud CDN domain names (separated by semicolons)", "workflow_node.deploy.form.wangsu_cdn_domains.help": "Notes: Multi-domains should be separated by semicolons.", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index c5f27d39..a5a95b81 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -202,6 +202,7 @@ "provider.volcengine_imagex": "火山引擎 - 图片服务 ImageX", "provider.volcengine_live": "火山引擎 - 视频直播 Live", "provider.volcengine_tos": "火山引擎 - 对象存储 TOS", + "provider.volcengine_vod": "火山引擎 - 视频点播 VOD", "provider.vultr": "Vultr", "provider.wangsu": "网宿云", "provider.wangsu_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 dbf6a3f5..e19d0fe7 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -984,6 +984,15 @@ "workflow_node.deploy.form.volcengine_tos_bucket.placeholder": "请输入火山引擎 TOS 存储桶名", "workflow_node.deploy.form.volcengine_tos_domain.label": "火山引擎 TOS 自定义域名", "workflow_node.deploy.form.volcengine_tos_domain.placeholder": "请输入火山引擎 TOS 自定义域名", + "workflow_node.deploy.form.volcengine_vod_space_name.label": "火山引擎 VOD 空间名称", + "workflow_node.deploy.form.volcengine_vod_space_name.placeholder": "请输入火山引擎 VOD 空间名称", + "workflow_node.deploy.form.volcengine_vod_space_name.tooltip": "这是什么?请参阅 https://console.volcengine.com/vod/overview", + "workflow_node.deploy.form.volcengine_vod_domain_type.label": "火山引擎 VOD 域名类型", + "workflow_node.deploy.form.volcengine_vod_domain_type.placeholder": "请选择火山引擎 VOD 域名类型", + "workflow_node.deploy.form.volcengine_vod_domain_type.option.play.label" : "点播加速域名", + "workflow_node.deploy.form.volcengine_vod_domain_type.option.image.label" : "封面加速域名", + "workflow_node.deploy.form.volcengine_vod_domain.label": "火山引擎 VOD 加速域名", + "workflow_node.deploy.form.volcengine_vod_domain.placeholder": "请输入火山引擎 VOD 加速域名", "workflow_node.deploy.form.wangsu_cdn_domains.label": "网宿云 CDN 加速域名", "workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "请输入网宿云 CDN 加速域名(多个值请用半角分号隔开)", "workflow_node.deploy.form.wangsu_cdn_domains.help": "提示:支持多个域名,以半角分号隔开。",